]> git.giacomolli.uk - onechat/commitdiff
Show an input prompt while the agent is idle
authorAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 02:46:12 +0000 (23:46 -0300)
committerAnderson A. Giacomolli <redacted>
Tue, 10 Nov 2026 02:52:52 +0000 (23:52 -0300)
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.

README.rst
terminal.go
terminal_test.go

index 23462b43ecaa2167feed93a769d1ee5de5c7be84..929272c9167c1a96b45116f05f56d88cf0531682 100644 (file)
@@ -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
 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.
 
 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.
index 203cb5e64adb2d6a8054f71e2b0431536e5c1331..9945620bdb5fd5329ef6e9e1bc32b922f2cef31a 100644 (file)
@@ -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
 
 // 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 {
 func terminalShowEvents(conn net.Conn, out io.Writer) error {
+       busy, prompt := false, false
        scanner := bufio.NewScanner(conn)
        scanner.Buffer(make([]byte, 64*1024), socketRequestBytes)
 
        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")
                }
 
                        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)
                }
                if err != nil {
                        return fmt.Errorf("write terminal event: %w", err)
                }
index 47791efae5cee4f791c97a23e5d01531eaee2c8b..7b68edb4ed389ceb09355c40c02b85d3d62a5376 100644 (file)
@@ -132,6 +132,7 @@ func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) {
        out := TerminalTestOutput{fd: fd}
        require.Equal(t, 12, terminalColumns(&out))
        terminalTestShowEvents(t, &out,
        out := TerminalTestOutput{fd: fd}
        require.Equal(t, 12, terminalColumns(&out))
        terminalTestShowEvents(t, &out,
+               Event{Kind: "busy"},
                Event{
                        Kind: "reply",
                        Text: "**first next last**",
                Event{
                        Kind: "reply",
                        Text: "**first next last**",
@@ -172,9 +173,9 @@ func TestTerminalStylesOnlyRepliesAndWork(t *testing.T) {
                Event{Kind: "busy"},
                Event{Kind: "idle"},
        )
                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"+
                "[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) {
 }
 
 func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) {
@@ -192,11 +193,91 @@ func TestTerminalZeroWidthUsesEightyAndReadsChanges(t *testing.T) {
                Kind: "reply",
                Text: strings.Repeat("a", 81),
        })
                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))
 }
 
        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 {
 // TerminalTestOutput captures text while it supplies a test terminal
 // descriptor.
 type TerminalTestOutput struct {