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.
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.
// 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)
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)
}
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: "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) {
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 {