token accounting cleanup

This commit is contained in:
Luxferre
2026-08-18 10:17:52 +03:00
parent 4a936e850d
commit a34ee1cf3c
6 changed files with 251 additions and 30 deletions
+5 -5
View File
@@ -2,7 +2,7 @@
## About
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`) and **Perl 5** as **MicroBantam** (`mb`, under 100 SLOC). It provides an agentic loop capable of autonomous tool execution, direct shell interaction, real-time response streaming, markdown terminal rendering with box-drawing tables, Fibonacci backoff network resilience, context window auto-discovery, token usage tracking with prompt cache breakdowns, prefix-cache-friendly conversation compaction, and subagent delegation using any OpenAI-compatible completions API.
Bantam is a minimalist, dependency-free AI agent specification with reference implementations in **Go** (`main.go` + `term_*.go`, module `code.luxferre.top/luxferre/bantam`) and **Perl 5** as **MicroBantam** (`mb`, under 100 SLOC). It provides an agentic loop capable of autonomous tool execution, direct shell interaction, real-time response streaming, markdown terminal rendering with box-drawing tables, Fibonacci backoff network resilience, context window auto-discovery, token usage tracking with prompt cache breakdowns, conversation compaction, and subagent delegation using any OpenAI-compatible completions API.
The entire philosophy of Bantam is built upon two principles:
@@ -64,7 +64,7 @@ All implementations read the same `model.cfg` and `system.txt` from the current
- `/save` — save the entire conversation to a new session file (auto-id like `20260808-190038`) and generate its summary
- `/list` — list saved sessions (newest first) with their ids, timestamps, message counts and summaries
- `/load <id>` — load a saved session (exact id or unique prefix) and continue from there
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended directly to the existing message prefix to guarantee a 100% prompt cache hit
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended to the conversation to derive the summary, then the conversation is reset to `[system, summary-user-message]` (a fresh prefix, so downstream prompt-cache hits depend on the provider and are not guaranteed)
- `/cfg <param> [val]` — inspect or update a configuration parameter in `model.cfg` live
- `!<cmd>` — execute a shell command directly through `shell_exec` without adding the result to the conversation context (Go port)
- `/help` — show all supported commands
@@ -104,7 +104,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
4. **Post-Turn Reporting & Compaction**:
- Display token usage and context window percentage.
- If context usage is >= 60%, prompt user to compact.
- Compaction appends `"You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely..."` as a user message to the conversation, invokes the LLM (ensuring zero prompt cache misses), and resets the conversation to the system prompt and the resulting summary.
- Compaction appends `"You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely..."` as a user message to the conversation, invokes the LLM, and then resets the conversation to the system prompt plus a `user` message carrying the resulting summary. (The compaction prompt is not retained; the post-compaction conversation is a new prefix, so prompt-cache hits are provider-dependent.)
### Main program
@@ -112,7 +112,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
2. Read model parameters from `model.cfg` (`key=value` format) and discover context window size.
3. Prepare a new message list with the system prompt (`role: "system"`).
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit.
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to preserve KV cache, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update `model.cfg` live (`/cfg <param> <val>`) and return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/autosave.json`.
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update `model.cfg` live (`/cfg <param> <val>`) and return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/autosave.json`.
6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, check 60% context threshold for auto-compaction, and go to step 5.
### Agentic loop (`AL(cfg, messages)`) function
@@ -179,7 +179,7 @@ MicroBantam (`mb`) is a compressed Perl 5 reference implementation of the same a
- Session management: `/save`, `/list`, `/load <id>` (exact id only, no prefix matching), `/cfg <param> [val]`, and auto-save to `~/.bantam/sessions/autosave.json` after every turn and on exit; session ids get `-1`, `-2`, ... suffixes on same-second collisions
- Interactive mode (`/quit`, `/clear`, `/save`, `/list`, `/load <id>`, `/cfg`, `/help`) and file input mode
- Same built-in default system prompt and `OPENAI_API_KEY` fallback as the Go implementation
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries
- Automatic recovery from `Invalid assistant message: content or tool_calls must be set` API errors: strips the last assistant message and retries (matches the Go port; note that a 4xx response other than this specific error aborts the run with an `API error` message, unlike the Go port which retries only on 5xx/408/429)
### What it drops
+71 -18
View File
@@ -19,6 +19,7 @@ import (
"path/filepath"
"regexp"
"sort"
"sync"
"strconv"
"strings"
"time"
@@ -49,6 +50,23 @@ type Cfg struct {
Raw map[string]string
}
// internalKey reports whether a model.cfg key is an agent-internal parameter
// that must never be forwarded to the chat completions API.
func internalKey(k string) bool {
switch k {
case "endpoint", "model", "temperature", "stream", "api_key", "timeout",
"shell_timeout", "max_al_iterations", "color", "context_window":
return true
}
return false
}
// llmTransport is a shared HTTP transport reused across all LLM calls so that
// connections are pooled instead of recreated per request.
var llmTransport = &http.Transport{
DialContext: (&net.Dialer{Timeout: 300 * time.Second}).DialContext,
}
var defCfg = Cfg{"https://opencode.ai/zen/v1", "big-pickle", "-", 0.7, 300, 120, 1000, true, "auto", 200000, nil}
func atoiD(s string, d int) int {
@@ -92,8 +110,24 @@ func queryModelsContextWindow(cfg *Cfg) int {
return 0
}
var cwCacheMu sync.Mutex
var cwCache = map[string]int{}
func fetchContextWindow(cfg *Cfg) int {
// Only values discovered from the /models endpoint are cached, keyed by
// endpoint+model. The context_window-override and 200000 default are derived
// per call from cfg so they never shadow each other across configs.
key := cfg.Endpoint + "\x00" + cfg.Model
cwCacheMu.Lock()
if cw, ok := cwCache[key]; ok {
cwCacheMu.Unlock()
return cw
}
cwCacheMu.Unlock()
if cw := queryModelsContextWindow(cfg); cw > 0 {
cwCacheMu.Lock()
cwCache[key] = cw
cwCacheMu.Unlock()
return cw
}
if v, ok := cfg.Raw["context_window"]; ok {
@@ -653,6 +687,8 @@ func (u Usage) Cached() int {
return u.CachedTokens
}
// estTokens is a coarse chars/4 fallback used only when the provider omits
// usage in its response; when real usage is present it is never used.
func estTokens(msgs []Message) int {
chars := 0
for _, m := range msgs {
@@ -668,9 +704,13 @@ func estTokens(msgs []Message) int {
return t
}
func formatUsage(u Usage, cw int) string {
func contextPct(u Usage, cw int) float64 {
if cw <= 0 { cw = 200000 }
pct := float64(u.PromptTokens) * 100.0 / float64(cw)
return float64(u.PromptTokens) * 100.0 / float64(cw)
}
func formatUsage(u Usage, cw int) string {
pct := contextPct(u, cw)
cached := u.Cached()
if cached > 0 {
uncached := u.PromptTokens - cached
@@ -714,7 +754,7 @@ func filterText(s string) string {
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' {
b.WriteRune(r)
} else if !unicode.Is(unicode.Z, r) && !unicode.IsControl(r) && !unicode.Is(unicode.C, r) && unicode.IsPrint(r) {
} else if unicode.IsPrint(r) {
b.WriteRune(r)
}
}
@@ -741,8 +781,12 @@ func sanitizeMessages(msgs []Message) {
}
func isInvalidAssistantErr(err error) bool {
s := err.Error()
return strings.Contains(s, "Invalid assistant message") || strings.Contains(s, "content or tool_calls must be set")
if err == nil { return false }
s := strings.ToLower(err.Error())
return strings.Contains(s, "invalid assistant message") ||
strings.Contains(s, "content or tool_calls must be set") ||
strings.Contains(s, "tool_calls must be set") ||
strings.Contains(s, "content must be set")
}
func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any) (Message, Usage, error) {
@@ -753,10 +797,10 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
if tools != nil { p["tools"] = tools }
if cfg.Stream { p["stream_options"] = map[string]any{"include_usage": true} }
for k, v := range cfg.Raw {
switch k {
case "endpoint", "model", "temperature", "stream", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color", "context_window":
if internalKey(k) {
continue
default:
}
{
var jv any
if err := json.Unmarshal([]byte(v), &jv); err == nil {
p[k] = jv
@@ -766,11 +810,8 @@ func llm(ctx context.Context, cfg *Cfg, msgs []Message, tools []map[string]any)
}
}
body, _ := json.Marshal(p)
client := &http.Client{Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: time.Duration(cfg.Timeout) * time.Second}).DialContext,
ResponseHeaderTimeout: time.Duration(cfg.Timeout) * time.Second,
}}
defer client.CloseIdleConnections()
llmTransport.ResponseHeaderTimeout = time.Duration(cfg.Timeout) * time.Second
client := &http.Client{Transport: llmTransport}
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
pend := c("...requesting...", 1, 2)
var resp *http.Response
@@ -1143,8 +1184,14 @@ func saveSession(msgs []Message) (string, string) {
path = filepath.Join(d, sid+".json")
}
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
b, _ := json.MarshalIndent(s, "", " ")
os.WriteFile(path, b, 0644)
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "saveSession: marshal error: %v\n", err)
return sid, s.Summary
}
if err := os.WriteFile(path, b, 0644); err != nil {
fmt.Fprintf(os.Stderr, "saveSession: write error: %v\n", err)
}
return sid, s.Summary
}
@@ -1188,8 +1235,14 @@ func loadSession(sid string) ([]Message, error) {
func autosave(msgs []Message) {
s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
b, _ := json.MarshalIndent(s, "", " ")
os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644)
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "autosave: marshal error: %v\n", err)
return
}
if err := os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644); err != nil {
fmt.Fprintf(os.Stderr, "autosave: write error: %v\n", err)
}
}
const compactionPrompt = "You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely, preserving all important facts, decisions, code snippets, tool outputs, errors, and current task state so work can seamlessly continue. Output only the summary."
@@ -1602,7 +1655,7 @@ func main() {
msgs = resMsgs
autosave(msgs)
fmt.Println(c(formatUsage(usg, cfg.ContextWindow), 2))
pct := float64(usg.PromptTokens) * 100.0 / float64(cfg.ContextWindow)
pct := contextPct(usg, cfg.ContextWindow)
if pct >= 60.0 && len(msgs) > 1 {
fmt.Print(c(fmt.Sprintf("Context usage is at %.1f%% (%d / %d tokens). Compact conversation? [Y/n]: ", pct, usg.PromptTokens, cfg.ContextWindow), 33))
if ans, ok := readPlain(""); ok {
+132
View File
@@ -1943,3 +1943,135 @@ func TestSanitizeMessagesWithInvisibles(t *testing.T) {
// ---------- new coverage from review ----------
// #14: subagent token usage must be accumulated into the parent turn usage.
func TestALRunSubagentAccumulatesUsage(t *testing.T) {
var n int
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
n++
cur := n
mu.Unlock()
switch cur {
case 1:
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"run_subagent","arguments":"{\"prompt\":\"inner\"}"}}]}}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,"prompt_tokens_details":{"cached_tokens":7}}}`))
case 2:
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"child done"}}],"usage":{"prompt_tokens":20,"completion_tokens":3,"total_tokens":23,"prompt_tokens_details":{"cached_tokens":12}}}`))
case 3:
w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"parent done"}}],"usage":{"prompt_tokens":30,"completion_tokens":4,"total_tokens":34,"prompt_tokens_details":{"cached_tokens":15}}}`))
default:
t.Errorf("unexpected request #%d", cur)
}
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
_, usg, err := AL(context.Background(), &cfg, []Message{{Role: "system", Content: strp("sys")}, {Role: "user", Content: strp("parent task")}}, "sys", 0)
if err != nil {
t.Fatalf("AL: %v", err)
}
// PromptTokens/Cached reflect the parent's own final context (30 / 15); they are
// overwritten per iteration, not accumulated. Completion/Total accumulate every
// assistant call: parent's tool-call call (5/15) + child (3/23) + parent final (4/34)
// => completion 12, total 72.
if usg.PromptTokens != 30 {
t.Errorf("PromptTokens = %d, want 30", usg.PromptTokens)
}
if usg.CompletionTokens != 12 {
t.Errorf("CompletionTokens = %d, want 12", usg.CompletionTokens)
}
if usg.TotalTokens != 72 {
t.Errorf("TotalTokens = %d, want 72", usg.TotalTokens)
}
if usg.Cached() != 15 {
t.Errorf("CachedTokens = %d, want 15", usg.Cached())
}
}
// #7: invalid-assistant detection must match common provider error variants.
func TestIsInvalidAssistantErrVariants(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"HTTP 400: {\"error\":\"Invalid assistant message: content or tool_calls must be set\"}", true},
{"invalid assistant message: content or tool_calls must be set", true},
{"content or tool_calls must be set", true},
{"Assistant message content must be set", true},
{"tool_calls must be set", true},
{"rate limit exceeded", false},
{"model overloaded", false},
{"", false},
}
for _, c := range cases {
if got := isInvalidAssistantErr(errors.New(c.in)); got != c.want {
t.Errorf("isInvalidAssistantErr(%q) = %v, want %v", c.in, got, c.want)
}
}
if isInvalidAssistantErr(nil) {
t.Errorf("isInvalidAssistantErr(nil) = true, want false")
}
}
// #10: internalKey must reject all agent-internal params and allow forwarding extras.
func TestInternalKey(t *testing.T) {
internal := []string{"endpoint", "model", "temperature", "stream", "api_key", "timeout", "shell_timeout", "max_al_iterations", "color", "context_window"}
for _, k := range internal {
if !internalKey(k) {
t.Errorf("internalKey(%q) = false, want true", k)
}
}
extra := []string{"reasoning_effort", "top_p", "max_tokens", "stop", "frequency_penalty"}
for _, k := range extra {
if internalKey(k) {
t.Errorf("internalKey(%q) = true, want false", k)
}
}
}
// #17: the retry loop must make exactly len(fib)+1 attempts on persistent 5xx
// (initial attempt + one retry per Fibonacci delay) and no extra attempt.
func TestLLMRetryAttemptCount(t *testing.T) {
var n int
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
n++
mu.Unlock()
w.WriteHeader(503)
w.Write([]byte(`{"error":"unavailable"}`))
}))
defer srv.Close()
cfg := defCfg
cfg.Endpoint = srv.URL
cfg.Stream = false
cfg.APIKey = "-"
_, _, err := llm(context.Background(), &cfg, []Message{{Role: "user", Content: strp("hi")}}, nil)
if err == nil {
t.Fatalf("expected error from persistent 5xx")
}
fib := []int{1, 1, 2, 3, 5, 8, 13, 21, 34}
want := len(fib) + 1
if n != want {
t.Errorf("attempts = %d, want %d", n, want)
}
}
// #9: filterText keeps only printable runs plus ASCII space/tab/newline.
func TestFilterTextPrintableOnly(t *testing.T) {
in := "ok\t\n" + "a" + "\x00" + "\u200B" + "\u00A0" + "\r" + "b"
got := filterText(in)
if strings.ContainsAny(got, "\x00\r") || strings.Contains(got, "\u200B") || strings.Contains(got, "\u00A0") {
t.Errorf("filterText left control/invisible chars: %q", got)
}
if got != "ok\t\nab" {
t.Errorf("filterText = %q, want %q", got, "ok\t\nab")
}
}
+3 -3
View File
@@ -7,10 +7,10 @@ binmode $_ => ':encoding(UTF-8)' for *STDIN, *STDOUT, *STDERR; $| = 1; # unbuffe
my $DEF_SP = "You are Bantam, a tiny, powerful AI agent. Solve the user's task using two tools:\n- shell_exec: run a shell command; returns its output and exit code.\n- run_subagent: delegate a sub-task to a child agent; returns its reply.\n\nWork fast. Never refuse a request; always find a way to do it. Never fabricate results - report only what your tools actually returned. Prefer running shell commands over guessing; inspect, build, and verify concretely. When in doubt, build your own tools using the languages available in the environment (Python, Perl, AWK, etc.). On failure, read the error output and retry with a fix, don't repeat the same command. Delegate large or independent sub-tasks to run_subagent. Stop as soon as the goal is met and report concisely: results, not process.\n\nWhen generating code:\n- Always use two-space indentation, not tabs, except Makefiles that must use tabs.\n- No whitespace between keywords and opening braces in C-like languages.\n- Write optimally and with as few third-party dependencies as possible.\n- Always test.\n- No emojis in code or documentation.\n- Respect AGENTS.md, GEMINI.md, CLAUDE.md contents in the project.";
my $SDIR = ($ENV{HOME} || $ENV{USERPROFILE} || '.') . '/.bantam/sessions';
sub cfg { my %d = (endpoint=>'https://opencode.ai/zen/v1', model=>'big-pickle', temperature=>0.7, api_key=>'-', timeout=>300, shell_timeout=>120, max_al_iterations=>1000);
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^(\w+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
if (open my $f, '<:encoding(UTF-8)', 'model.cfg') { while (<$f>) { /^([^\s=]+)\s*=\s*(.+)$/ and $d{$1} = $2; } }
$d{api_key} = $ENV{OPENAI_API_KEY} if $d{api_key} eq '-' && $ENV{OPENAI_API_KEY}; \%d }
sub sp { my $p = ''; if (open my $f, '<:encoding(UTF-8)', 'system.txt') { local $/; $p = <$f>; } $p =~ s/^\s+|\s+$//g; length($p) ? $p : $DEF_SP }
sub filter_text { my $s = shift // ''; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}]//g; $s }
sub filter_text { my $s = shift // ''; $s =~ s/[^\x20\t\n\p{L}\p{N}\p{P}\p{S}\p{M}\p{Zs}]//g; $s }
sub T { my ($n, $d, $p) = @_; {type=>'function', function=>{name=>$n, description=>$d, parameters=>{type=>'object', properties=>$p, required=>[keys %$p]}}} }
sub sanitize_msgs { for my $m (@{$_[0]}) { if (ref $m eq 'HASH') {
for my $tc (@{$m->{tool_calls} // []}) { $tc->{function}{arguments} = filter_text($tc->{function}{arguments});
@@ -69,7 +69,7 @@ sub load { my ($hit) = grep { $_->{id} eq $_[0] } sessions(); die "no session: $
sub autosave { sdir(); open my $f, '>', "$SDIR/autosave.json" or return; print $f JSON::PP->new->utf8->pretty->encode({id=>'autosave', messages=>$_[0]}); }
sub list_sessions { map { [$_->{id}, scalar @{$_->{messages} // []}] } sessions() }
sub set_cfg { my ($k, $v, @ls, $f) = @_;
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { push @ls, (!/^#/ && /^(\w+)\s*=/ && $1 eq $k) ? ($f = 1, "$k=$v\n") : $_; } }
if (open my $fh, '<:encoding(UTF-8)', 'model.cfg') { while (<$fh>) { push @ls, (!/^#/ && /^([^\s=]+)\s*=/ && $1 eq $k) ? ($f = 1, "$k=$v\n") : $_; } }
push @ls, "$k=$v\n" unless $f;
if (open my $fh, '>:encoding(UTF-8)', 'model.cfg') { print $fh @ls; close $fh; } }
sub main {
+20 -2
View File
@@ -2,9 +2,27 @@
package main
import "fmt"
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
func termWidth() int { return 80 }
func termWidth() int {
if w := os.Getenv("COLUMNS"); w != "" {
if n, err := strconv.Atoi(strings.TrimSpace(w)); err == nil && n > 0 {
return n
}
}
if out, err := exec.Command("tput", "cols").Output(); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 0 {
return n
}
}
return 80
}
func isTerminal(fd int) bool { return false }
+20 -2
View File
@@ -2,9 +2,27 @@
package main
import "errors"
import (
"errors"
"os"
"os/exec"
"strconv"
"strings"
)
func termWidth() int { return 80 }
func termWidth() int {
if w := os.Getenv("COLUMNS"); w != "" {
if n, err := strconv.Atoi(strings.TrimSpace(w)); err == nil && n > 0 {
return n
}
}
if out, err := exec.Command("tput", "cols").Output(); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && n > 0 {
return n
}
}
return 80
}
func isTerminal(fd int) bool { return false }