From: Anderson A. Giacomolli Date: Tue, 10 Nov 2026 02:46:12 +0000 (-0300) Subject: Show an input prompt while the agent is idle X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/ae8532ff0e9d3d676fec5f48780a62e58aa6882d?ds=inline Show an input prompt while the agent is idle The terminal client ends shown events with a prompt while no turn runs. Busy and idle events track the turn, and the next shown event clears the prompt before it prints. Pipes and files keep the same output. The small prompt does not keep a typed line separate from incoming events. --- diff --git a/README.rst b/README.rst index 23462b4..929272c 100644 --- a/README.rst +++ b/README.rst @@ -82,17 +82,19 @@ The daemon owns the root agent in ``agent/`` in the data directory and listens on the Unix socket ``onechat.sock`` there. In the client, each line is a message to the agent, the line ``/stop`` interrupts the running turn, the line ``/stop NAME`` stops the subagent NAME, and -Ctrl-D ends the client. The client shows the replies, the tool calls, -and the first lines of each tool result as they happen. Replies and -subagent reports render Markdown with styles and wrapped tables only -when the output is a terminal, and stay unchanged in a pipe or file. It -also shows each message that the user sends from another client, with -the client as a mark, such as ``[telegram] text``. When a subagent -starts, the client shows ``[NAME] started``, and it shows the whole -report of the subagent, which starts with ``[NAME]``, but not the tool -calls of the subagent. The daemon is the only process that uses the -token file while it runs, so ``onechat login`` and ``onechat probe`` -refuse to start until it stops. +Ctrl-D ends the client. On terminal output, the client shows ``> `` +after each event while idle and clears it before the next event. An +event can interrupt a line while the user types. The client shows the +replies, the tool calls, and the first lines of each tool result as they +happen. Replies and subagent reports render Markdown with styles and +wrapped tables only when the output is a terminal, and stay unchanged in +a pipe or file. It also shows each message that the user sends from +another client, with the client as a mark, such as ``[telegram] text``. +When a subagent starts, the client shows ``[NAME] started``, and it +shows the whole report of the subagent, which starts with ``[NAME]``, +but not the tool calls of the subagent. The daemon is the only process +that uses the token file while it runs, so ``onechat login`` and +``onechat probe`` refuse to start until it stops. The agent runs commands with bash as the user in its working directory, with no sandbox. It has the rights of the user, also on the token file. diff --git a/terminal.go b/terminal.go index 203cb5e..9945620 100644 --- a/terminal.go +++ b/terminal.go @@ -134,8 +134,10 @@ func terminalSendLines(ctx context.Context, conn net.Conn, in io.Reader) error { // terminalShowEvents writes each event from the daemon to out until the // connection closes. Only replies and work messages use Markdown styles, and -// only when out is a terminal. +// only when out is a terminal. Terminal output ends each shown idle event with +// an input prompt and clears that prompt before the next shown event. func terminalShowEvents(conn net.Conn, out io.Writer) error { + busy, prompt := false, false scanner := bufio.NewScanner(conn) scanner.Buffer(make([]byte, 64*1024), socketRequestBytes) @@ -157,7 +159,24 @@ func terminalShowEvents(conn net.Conn, out io.Writer) error { text = strings.Join(terminalMarkdown(text, columns), "\n") } - _, err = io.WriteString(out, text+"\n") + switch event.Kind { + case "busy": + busy = true + case "idle": + busy = false + } + + text += "\n" + if prompt { + text = "\r\x1b[2K" + text + } + + prompt = columns > 0 && !busy + if prompt { + text += "> " + } + + _, err = io.WriteString(out, text) if err != nil { return fmt.Errorf("write terminal event: %w", err) } diff --git a/terminal_test.go b/terminal_test.go index 47791ef..7b68edb 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -132,6 +132,7 @@ func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) { out := TerminalTestOutput{fd: fd} require.Equal(t, 12, terminalColumns(&out)) terminalTestShowEvents(t, &out, + Event{Kind: "busy"}, Event{ Kind: "reply", Text: "**first next last**", @@ -172,9 +173,9 @@ func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) { Event{Kind: "busy"}, Event{Kind: "idle"}, ) - require.Equal(t, "\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+ + require.Equal(t, "[working]\n\x1b[1mfirst next\x1b[0m\n\x1b[1mlast\x1b[0m\n[BlueBird]\n\x1b[1mReport\x1b[0m\n"+ "[tool] **raw tool**\n[result] **raw result**\n[error] **raw error**\n**raw notice**\n[telegram] **raw input**\n"+ - "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n", out.String()) + "[connected to **raw agent**]\n[BlueBird] started\n[working]\n[done]\n> ", out.String()) } func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) { @@ -192,11 +193,91 @@ func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) { Kind: "reply", Text: strings.Repeat("a", 81), }) - require.Equal(t, strings.Repeat("a", 80)+"\na\n", out.String()) + require.Equal(t, strings.Repeat("a", 80)+"\na\n> ", out.String()) require.NoError(t, unix.IoctlSetWinsize(fd, unix.TIOCSWINSZ, &unix.Winsize{Col: 40})) require.Equal(t, 40, terminalColumns(&out)) } +func TestTerminalPromptsAfterIdleEventsAndClearsBeforeShownEvents(t *testing.T) { + fd, err := unix.Open("/dev/ptmx", unix.O_RDWR|unix.O_NOCTTY, 0) + require.NoError(t, err) + + defer func() { + require.NoError(t, unix.Close(fd)) + }() + + out := TerminalTestOutput{fd: fd} + terminalTestShowEvents(t, &out, + Event{ + Kind: "ready", + Text: "Example", + }, + Event{ + Kind: "user", + Source: terminalSource, + Text: "hidden", + }, + Event{Kind: "unknown"}, + Event{ + Kind: "notice", + Text: "Idle notice", + }, + Event{ + Kind: "work", + Text: "**Idle report**", + }, + Event{Kind: "busy"}, + Event{ + Kind: "notice", + Text: "Busy notice", + }, + Event{ + Kind: "reply", + Text: "**Answer**", + }, + Event{Kind: "idle"}, + Event{ + Kind: "error", + Text: "Idle error", + }, + ) + require.Equal(t, "[connected to Example]\n> "+ + "\r\x1b[2KIdle notice\n> "+ + "\r\x1b[2K\x1b[1mIdle report\x1b[0m\n> "+ + "\r\x1b[2K[working]\nBusy notice\n\x1b[1mAnswer\x1b[0m\n[done]\n> "+ + "\r\x1b[2K[error] Idle error\n> ", out.String()) +} + +func TestTerminalNonterminalEventsHaveNoPromptOrClearCommands(t *testing.T) { + var out strings.Builder + + terminalTestShowEvents(t, &out, + Event{ + Kind: "ready", + Text: "Example", + }, + Event{ + Kind: "notice", + Text: "Idle notice", + }, + Event{Kind: "busy"}, + Event{ + Kind: "notice", + Text: "Busy notice", + }, + Event{ + Kind: "reply", + Text: "**Answer**", + }, + Event{Kind: "idle"}, + Event{ + Kind: "notice", + Text: "Idle again", + }, + ) + require.Equal(t, "[connected to Example]\nIdle notice\n[working]\nBusy notice\n**Answer**\n[done]\nIdle again\n", out.String()) +} + // TerminalTestOutput captures text while it supplies a test terminal // descriptor. type TerminalTestOutput struct {