@buzzni/saycode-cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +21 -6
  2. package/index.mjs +452 -115
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -103,15 +103,22 @@ saycode chat -s <session> # 기존 세션 이어서 대화 (직전 20개
103
103
 
104
104
  ## 대화에서 나가기
105
105
 
106
- 에이전트가 작업 중일 때 **`Ctrl+C`를 누르면 선택지가 뜹니다.**
106
+ 채팅 하단 상태가 현재 입력 가능 여부를 그대로 나타냅니다.
107
107
 
108
108
  ```
109
- ⏳ working — Ctrl+C for options
109
+ ● Ready — you can type
110
+ › 다음 작업을 진행해줘
111
+ ● Agent is responding — input locked · Ctrl+C for options
110
112
  ⏺ Bash(npm test)
111
113
  │ running tests...
112
114
  ^C [Enter] keep watching · [d] detach, agent keeps working · [s] stop the response
113
115
  ```
114
116
 
117
+ `Agent is responding` 동안 stdin은 raw/no-echo로 잠겨 일반 문자와 Enter가 화면에 나타나거나 다음
118
+ 프롬프트에 쌓이지 않습니다. 권한 요청 때만 `◆ Action required — permission response only`로 바뀌고
119
+ 해당 선택 입력을 받습니다. 에이전트 턴이 끝나 `Ready`가 표시된 뒤에만 일반 프롬프트가 열립니다.
120
+
121
+ 에이전트가 작업 중일 때 **`Ctrl+C`를 누르면 선택지가 뜹니다.**
115
122
  한 글자만 누르면 됩니다 (Enter 불필요). `Ctrl+C`를 한 번 더 누르면 `d`와 같습니다 —
116
123
  반복해서 누르는 건 "빨리 나가자"는 뜻이고, 나가는 건 되돌릴 수 있지만 작업을 죽이는 건
117
124
  되돌릴 수 없기 때문입니다.
@@ -128,9 +135,17 @@ saycode chat -s <session> # 기존 세션 이어서 대화 (직전 20개
128
135
  **어느 경우에도 원격 세션은 죽지 않습니다.** 데스크탑이나 다른 터미널에서 하던 작업을
129
136
  CLI를 닫았다는 이유로 파괴하지 않습니다.
130
137
 
131
- 응답이 오래 걸릴 때 `/detach`로 빠져나와 `saycode watch`로 진행만 지켜보다가,
138
+ 응답이 오래 걸릴 때 `Ctrl+C` → `d`로 빠져나와 `saycode watch`로 진행만 지켜보다가,
132
139
  끝나면 `saycode chat -s <id>`로 돌아오는 흐름을 의도했습니다.
133
140
 
141
+ ## 답변 표시
142
+
143
+ 에이전트 답변의 제목·목록·인용·굵게·기울임·취소선·인라인 코드·링크·fenced code block을
144
+ 터미널 표현으로 바꾸고, 현재 터미널 폭에서 좌우 여백을 뺀 최대 100열로 줄바꿈합니다. 한글과
145
+ emoji의 표시 폭도 반영합니다. 링크는 파이프 출력에서도 목적지를 잃지 않도록 URL을 함께 남깁니다.
146
+ 긴 도구 stdout은 첫 3줄까지만 표시하고 종료 시 생략한 줄 수와 마지막 줄을 요약하며, stderr는
147
+ 숨기지 않습니다. `NO_COLOR`이거나 파이프로 출력해도 `◆`, `•`, `│`, `┌─` 같은 구조 표시는 남습니다.
148
+
134
149
  ## 권한 승인
135
150
 
136
151
  에이전트가 권한을 요청하면 인라인으로 물어봅니다.
@@ -154,11 +169,11 @@ GitHub Actions 가 태그로 발행합니다 (`.github/workflows/publish-saycode
154
169
 
155
170
  ```bash
156
171
  # 1. 버전 올리기 — 데스크탑 앱 버전과 무관합니다
157
- vi cli/manifest.json # "version": "0.1.1"
172
+ vi cli/manifest.json # "version": "0.1.2"
158
173
 
159
174
  # 2. 커밋하고 태그를 밀면 끝
160
- git tag saycode-cli-v0.1.1
161
- git push origin saycode-cli-v0.1.1
175
+ git tag saycode-cli-v0.1.2
176
+ git push origin saycode-cli-v0.1.2
162
177
  ```
163
178
 
164
179
  워크플로는 발행 전에 이 순서로 막습니다:
package/index.mjs CHANGED
@@ -37,7 +37,7 @@ function _mergeNamespaces(n, m) {
37
37
  }
38
38
  return Object.freeze(Object.defineProperty(n, Symbol.toStringTag, { value: "Module" }));
39
39
  }
40
- const version = "0.1.1";
40
+ const version = "0.1.2";
41
41
  const manifest = {
42
42
  version
43
43
  };
@@ -23565,7 +23565,7 @@ class CliRest {
23565
23565
  /** Newest-last page of a session transcript, used for `attach --since`. */
23566
23566
  async recentMessages(sessionId, limit) {
23567
23567
  const data = await this.get(
23568
- `/v3/sessions/${encodeURIComponent(sessionId)}/messages?limit=${encodeURIComponent(String(limit))}`
23568
+ `/v3/sessions/${encodeURIComponent(sessionId)}/messages?${new URLSearchParams({ limit: String(limit), before_seq: "2147483647" })}`
23569
23569
  );
23570
23570
  return data.messages ?? [];
23571
23571
  }
@@ -23625,17 +23625,34 @@ const RESET = "\x1B[0m";
23625
23625
  const STYLE_CODES = {
23626
23626
  user: "\x1B[1;36m",
23627
23627
  thinking: "\x1B[2;37m",
23628
- tool: "\x1B[33m",
23628
+ tool: "\x1B[2;33m",
23629
23629
  progress: "\x1B[2;37m",
23630
23630
  status: "\x1B[32m",
23631
- error: "\x1B[31m"
23631
+ error: "\x1B[31m",
23632
+ heading: "\x1B[1;36m",
23633
+ quote: "\x1B[2;37m",
23634
+ code: "\x1B[36m"
23632
23635
  // `text` is intentionally absent: agent prose is the main content and reads
23633
23636
  // best in the terminal's own foreground colour.
23634
23637
  };
23638
+ const INLINE_STYLE_CODES = {
23639
+ strong: "\x1B[1m",
23640
+ emphasis: "\x1B[3m",
23641
+ inlineCode: "\x1B[36m",
23642
+ strike: "\x1B[9m",
23643
+ link: "\x1B[4;36m"
23644
+ };
23645
+ function formatSpans(spans, baseCode) {
23646
+ return spans.map((span) => {
23647
+ if (!span.style) return span.text;
23648
+ return `${INLINE_STYLE_CODES[span.style]}${span.text}${baseCode ?? RESET}`;
23649
+ }).join("");
23650
+ }
23635
23651
  function formatLine(line, colour) {
23636
23652
  if (line.style === "divider") return "";
23637
23653
  const code = colour ? STYLE_CODES[line.style] : void 0;
23638
- return code ? `${code}${line.text}${RESET}` : line.text;
23654
+ const text2 = colour && line.spans ? formatSpans(line.spans, code) : line.text;
23655
+ return code ? `${code}${text2}${RESET}` : text2;
23639
23656
  }
23640
23657
  function colourEnabled(env, isTty) {
23641
23658
  if (env.NO_COLOR !== void 0) return false;
@@ -23854,7 +23871,171 @@ function runLogout(out) {
23854
23871
  }
23855
23872
  out.raw("No stored Saycode credentials. Happy credentials (~/.happy*/access.key) are untouched.");
23856
23873
  }
23874
+ function isCombining(codePoint, character) {
23875
+ return codePoint === 8205 || new RegExp("\\p{Mark}", "u").test(character);
23876
+ }
23877
+ function isWide(codePoint) {
23878
+ return codePoint >= 4352 && (codePoint <= 4447 || codePoint === 9001 || codePoint === 9002 || codePoint >= 11904 && codePoint <= 42191 && codePoint !== 12351 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65040 && codePoint <= 65049 || codePoint >= 65072 && codePoint <= 65135 || codePoint >= 65280 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 127744 && codePoint <= 129791 || codePoint >= 131072 && codePoint <= 262141);
23879
+ }
23880
+ function displayWidth(text2) {
23881
+ let width = 0;
23882
+ for (const character of text2) {
23883
+ const codePoint = character.codePointAt(0) ?? 0;
23884
+ if (codePoint < 32 || codePoint >= 127 && codePoint < 160 || isCombining(codePoint, character)) continue;
23885
+ width += isWide(codePoint) ? 2 : 1;
23886
+ }
23887
+ return width;
23888
+ }
23889
+ function styledToken(source, marker, style) {
23890
+ if (!source.startsWith(marker)) return null;
23891
+ const end = source.indexOf(marker, marker.length);
23892
+ if (end < marker.length + 1) return null;
23893
+ const text2 = source.slice(marker.length, end);
23894
+ if (text2.trim() === "") return null;
23895
+ return { length: end + marker.length, spans: [{ style, text: text2 }] };
23896
+ }
23897
+ function tokenAt(source) {
23898
+ const escaped = source.match(/^\\([\\`*_[\]~()])/);
23899
+ if (escaped) return { length: escaped[0].length, spans: [{ text: escaped[1] ?? "" }] };
23900
+ const code = source.match(/^`([^`\n]+)`/);
23901
+ if (code) return { length: code[0].length, spans: [{ style: "inlineCode", text: code[1] ?? "" }] };
23902
+ const link = source.match(/^\[([^\]\n]+)]\((https?:\/\/[^\s)\n]+)\)/);
23903
+ if (link) {
23904
+ const text2 = link[1] ?? "";
23905
+ const href = link[2] ?? "";
23906
+ return {
23907
+ length: link[0].length,
23908
+ spans: [{ href, style: "link", text: text2 }, { text: ` (${href})` }]
23909
+ };
23910
+ }
23911
+ return styledToken(source, "**", "strong") ?? styledToken(source, "~~", "strike") ?? (source.startsWith("**") ? null : styledToken(source, "*", "emphasis"));
23912
+ }
23913
+ function appendSpan(spans, span) {
23914
+ if (span.text === "") return;
23915
+ const previous = spans.at(-1);
23916
+ if (previous && previous.style === span.style && previous.href === span.href) {
23917
+ previous.text += span.text;
23918
+ return;
23919
+ }
23920
+ spans.push({ ...span });
23921
+ }
23922
+ function parseInlineMarkdown(source) {
23923
+ const spans = [];
23924
+ let offset = 0;
23925
+ while (offset < source.length) {
23926
+ const token = tokenAt(source.slice(offset));
23927
+ if (token) {
23928
+ for (const span of token.spans) appendSpan(spans, span);
23929
+ offset += token.length;
23930
+ continue;
23931
+ }
23932
+ const character = String.fromCodePoint(source.codePointAt(offset) ?? 0);
23933
+ appendSpan(spans, { text: character });
23934
+ offset += character.length;
23935
+ }
23936
+ return spans;
23937
+ }
23938
+ function finishLine(spans) {
23939
+ while (spans.at(-1)?.text.endsWith(" ")) {
23940
+ const last = spans.at(-1);
23941
+ if (!last) break;
23942
+ last.text = last.text.trimEnd();
23943
+ if (last.text === "") spans.pop();
23944
+ }
23945
+ return { text: spans.map((span) => span.text).join(""), spans };
23946
+ }
23947
+ function renderInlineMarkdown(source, width) {
23948
+ const parsed = parseInlineMarkdown(source);
23949
+ const lines = [];
23950
+ let current = [];
23951
+ let currentWidth = 0;
23952
+ for (const span of parsed) {
23953
+ for (const character of span.text) {
23954
+ const characterWidth = displayWidth(character);
23955
+ if (current.length > 0 && currentWidth + characterWidth > width) {
23956
+ lines.push(finishLine(current));
23957
+ current = [];
23958
+ currentWidth = 0;
23959
+ }
23960
+ if (current.length === 0 && character === " ") continue;
23961
+ appendSpan(current, { ...span, text: character });
23962
+ currentWidth += characterWidth;
23963
+ }
23964
+ }
23965
+ if (current.length > 0 || lines.length === 0) lines.push(finishLine(current));
23966
+ return lines;
23967
+ }
23968
+ function withPrefix(style, prefix, line) {
23969
+ if (!line.spans.some((span) => span.style)) return { style, text: `${prefix}${line.text}` };
23970
+ return { style, text: `${prefix}${line.text}`, spans: [...prefix ? [{ text: prefix }] : [], ...line.spans] };
23971
+ }
23972
+ class TerminalMarkdownRenderer {
23973
+ constructor(width) {
23974
+ this.width = width;
23975
+ }
23976
+ pending = "";
23977
+ inFence = false;
23978
+ get hasPending() {
23979
+ return this.pending !== "";
23980
+ }
23981
+ push(chunk) {
23982
+ const normalized = `${this.pending}${chunk}`.replace(/\r\n?/g, "\n");
23983
+ const sourceLines = normalized.split("\n");
23984
+ this.pending = sourceLines.pop() ?? "";
23985
+ return sourceLines.flatMap((line) => this.renderLine(line));
23986
+ }
23987
+ flush() {
23988
+ const pending = this.pending;
23989
+ this.pending = "";
23990
+ const lines = pending === "" ? [] : this.renderLine(pending);
23991
+ if (!this.inFence) return lines;
23992
+ this.inFence = false;
23993
+ return [...lines, { style: "code", text: "└─" }];
23994
+ }
23995
+ prefixed(style, prefix, content, continuation = " ") {
23996
+ const firstWidth = Math.max(1, this.width - displayWidth(prefix));
23997
+ const wrapped = renderInlineMarkdown(content, firstWidth);
23998
+ const lines = [withPrefix(style, prefix, wrapped[0] ?? { spans: [], text: "" })];
23999
+ for (const part of wrapped.slice(1)) {
24000
+ lines.push(withPrefix(style, continuation, part));
24001
+ }
24002
+ return lines;
24003
+ }
24004
+ renderLine(source) {
24005
+ const fence = source.match(/^\s*```\s*([^`]*)$/);
24006
+ if (fence) {
24007
+ if (this.inFence) {
24008
+ this.inFence = false;
24009
+ return [{ style: "code", text: "└─" }];
24010
+ }
24011
+ this.inFence = true;
24012
+ const language = fence[1]?.trim();
24013
+ return [{ style: "code", text: language ? `┌─ ${language}` : "┌─" }];
24014
+ }
24015
+ if (this.inFence) return this.prefixed("code", "│ ", source, "│ ");
24016
+ if (source.trim() === "") return [{ style: "divider", text: "" }];
24017
+ const heading = source.match(/^\s{0,3}#{1,6}\s+(.+)$/);
24018
+ if (heading) return this.prefixed("heading", "◆ ", heading[1] ?? "");
24019
+ const quote = source.match(/^\s*>\s?(.*)$/);
24020
+ if (quote) return this.prefixed("quote", "│ ", quote[1] ?? "", "│ ");
24021
+ const list = source.match(/^(\s*)([-+*]|\d+\.)\s+(.+)$/);
24022
+ if (list) {
24023
+ const indent = list[1] ?? "";
24024
+ const marker = /^\d/.test(list[2] ?? "") ? list[2] : "•";
24025
+ const prefix = `${indent}${marker} `;
24026
+ return this.prefixed("text", prefix, list[3] ?? "", " ".repeat(displayWidth(prefix)));
24027
+ }
24028
+ return renderInlineMarkdown(source, this.width).map((line) => withPrefix("text", "", line));
24029
+ }
24030
+ }
24031
+ function createTerminalMarkdownRenderer(width = 100) {
24032
+ return new TerminalMarkdownRenderer(Math.max(8, width));
24033
+ }
24034
+ function terminalContentWidth(columns) {
24035
+ return Math.max(20, Math.min(100, (columns ?? 104) - 4));
24036
+ }
23857
24037
  const TOOL_SUMMARY_MAX_LENGTH = 80;
24038
+ const TOOL_PROGRESS_VISIBLE_LINES = 3;
23858
24039
  const TOOL_SUMMARY_KEYS = ["command", "file_path", "path", "pattern", "query", "url", "prompt"];
23859
24040
  function truncate(value2, max) {
23860
24041
  if (value2.length <= max) return value2;
@@ -23877,7 +24058,7 @@ function textLines(text2, style) {
23877
24058
  if (text2.trim() === "") return [];
23878
24059
  return text2.split("\n").map((line) => ({ style, text: line }));
23879
24060
  }
23880
- function renderSessionEvent(ev, envelopeRole) {
24061
+ function renderSessionEvent(ev, envelopeRole, progressByCall) {
23881
24062
  switch (ev.t) {
23882
24063
  case "text":
23883
24064
  if (envelopeRole === "user") return textLines(ev.text, "user").map((line) => ({ ...line, text: `› ${line.text}` }));
@@ -23885,16 +24066,35 @@ function renderSessionEvent(ev, envelopeRole) {
23885
24066
  case "service":
23886
24067
  return textLines(ev.text, "status");
23887
24068
  case "tool-call-start": {
24069
+ progressByCall.set(ev.call, { shown: 0, hidden: 0, lastHidden: null });
23888
24070
  const summary = summarizeToolArgs(ev.args) ?? (ev.description?.trim() || null);
23889
24071
  return [{ style: "tool", text: summary ? `⏺ ${ev.name}(${summary})` : `⏺ ${ev.name}` }];
23890
24072
  }
23891
- case "tool-call-progress":
23892
- return ev.lines.map((line) => ({
23893
- style: ev.stream === "stderr" ? "error" : "progress",
23894
- text: ` │ ${line}`
23895
- }));
23896
- case "tool-call-end":
23897
- return [];
24073
+ case "tool-call-progress": {
24074
+ const state = progressByCall.get(ev.call) ?? { shown: 0, hidden: 0, lastHidden: null };
24075
+ progressByCall.set(ev.call, state);
24076
+ const lines = [];
24077
+ for (const line of ev.lines) {
24078
+ if (ev.stream === "stderr") lines.push({ style: "error", text: ` │ ${line}` });
24079
+ else if (state.shown < TOOL_PROGRESS_VISIBLE_LINES) {
24080
+ state.shown += 1;
24081
+ lines.push({ style: "progress", text: ` │ ${line}` });
24082
+ } else {
24083
+ state.hidden += 1;
24084
+ state.lastHidden = line;
24085
+ }
24086
+ }
24087
+ return lines;
24088
+ }
24089
+ case "tool-call-end": {
24090
+ const state = progressByCall.get(ev.call);
24091
+ progressByCall.delete(ev.call);
24092
+ if (!state?.hidden || !state.lastHidden) return [];
24093
+ return [{
24094
+ style: "progress",
24095
+ text: ` └ ${state.hidden} more lines hidden · last: ${truncate(state.lastHidden, TOOL_SUMMARY_MAX_LENGTH)}`
24096
+ }];
24097
+ }
23898
24098
  case "file":
23899
24099
  return [{ style: "status", text: `📎 ${ev.name} (${formatBytes(ev.size)})` }];
23900
24100
  case "turn-start":
@@ -23907,14 +24107,34 @@ function renderSessionEvent(ev, envelopeRole) {
23907
24107
  return [];
23908
24108
  }
23909
24109
  }
23910
- function renderMessageContent(content) {
23911
- if (content.role === "user") {
23912
- return textLines(content.content.text, "user").map((line) => ({ ...line, text: `› ${line.text}` }));
24110
+ class SessionEventRenderer {
24111
+ markdown;
24112
+ progressByCall = /* @__PURE__ */ new Map();
24113
+ constructor(width) {
24114
+ this.markdown = createTerminalMarkdownRenderer(width);
24115
+ }
24116
+ flush() {
24117
+ return this.markdown.flush();
23913
24118
  }
23914
- if (content.role === "session") {
23915
- return renderSessionEvent(content.content.ev, content.content.role);
24119
+ render(content) {
24120
+ if (content.role === "user") {
24121
+ return [...this.flush(), ...textLines(content.content.text, "user").map((line) => ({ ...line, text: `› ${line.text}` }))];
24122
+ }
24123
+ if (content.role !== "session") return [];
24124
+ const { ev, role } = content.content;
24125
+ if (ev.t === "text" && role === "agent" && !ev.thinking) {
24126
+ if (!this.markdown.hasPending && ev.text.trim() === "") return [];
24127
+ return this.markdown.push(ev.text);
24128
+ }
24129
+ const prefix = this.flush();
24130
+ if (ev.t === "turn-start") this.progressByCall.clear();
24131
+ const rendered = renderSessionEvent(ev, role, this.progressByCall);
24132
+ if (ev.t === "turn-end") this.progressByCall.clear();
24133
+ return [...prefix, ...rendered];
23916
24134
  }
23917
- return [];
24135
+ }
24136
+ function createSessionEventRenderer(width = 100) {
24137
+ return new SessionEventRenderer(width);
23918
24138
  }
23919
24139
  function record(value2) {
23920
24140
  return value2 && typeof value2 === "object" && !Array.isArray(value2) ? value2 : null;
@@ -23952,10 +24172,10 @@ function decodeContent(transport, ciphertext) {
23952
24172
  return null;
23953
24173
  }
23954
24174
  }
23955
- function printContent(context, content) {
23956
- context.out.lines(renderMessageContent(content));
24175
+ function printContent(context, content, renderer = createSessionEventRenderer()) {
24176
+ context.out.lines(renderer.render(content));
23957
24177
  }
23958
- async function prerollHistory(context, transport, sessionId, since) {
24178
+ async function prerollHistory(context, transport, sessionId, since, onContent, renderer = createSessionEventRenderer()) {
23959
24179
  if (since <= 0) return 0;
23960
24180
  const messages = await context.rest.recentMessages(sessionId, since);
23961
24181
  let highestSeq = 0;
@@ -23964,28 +24184,31 @@ async function prerollHistory(context, transport, sessionId, since) {
23964
24184
  const ciphertext = unwrapEncryptedField(message.content);
23965
24185
  if (!ciphertext) continue;
23966
24186
  const content = decodeContent(transport, ciphertext);
23967
- if (content) printContent(context, content);
24187
+ if (!content) continue;
24188
+ printContent(context, content, renderer);
24189
+ onContent?.(content);
23968
24190
  }
23969
24191
  return highestSeq;
23970
24192
  }
23971
24193
  function followSession(input) {
23972
24194
  let highestSeq = input.seenThroughSeq;
24195
+ const renderer = input.renderer ?? createSessionEventRenderer();
23973
24196
  return input.transport.onUpdate((update) => {
23974
24197
  input.onUpdate?.(update);
23975
24198
  const message = readNewMessage(update, input.sessionId);
23976
- if (!message) return;
23977
- if (message.localId && input.ownLocalIds?.has(message.localId)) return;
24199
+ if (!message || message.localId && input.ownLocalIds?.has(message.localId)) return;
23978
24200
  if (message.seq !== 0 && message.seq <= highestSeq) return;
23979
24201
  highestSeq = Math.max(highestSeq, message.seq);
23980
24202
  const content = decodeContent(input.transport, message.ciphertext);
23981
24203
  if (!content) return;
23982
- input.onContent ? input.onContent(content) : printContent(input.context, content);
24204
+ input.onContent ? input.onContent(content) : printContent(input.context, content, renderer);
23983
24205
  });
23984
24206
  }
23985
24207
  async function runAttach(context, options) {
23986
24208
  await withTransport(context, async (transport) => {
23987
- const seenThroughSeq = await prerollHistory(context, transport, options.sessionId, options.since);
23988
- followSession({ context, transport, sessionId: options.sessionId, seenThroughSeq });
24209
+ const renderer = createSessionEventRenderer(terminalContentWidth(process.stdout.columns));
24210
+ const seenThroughSeq = await prerollHistory(context, transport, options.sessionId, options.since, void 0, renderer);
24211
+ followSession({ context, transport, sessionId: options.sessionId, seenThroughSeq, renderer });
23989
24212
  context.out.raw(`Attached to ${options.sessionId}. Ctrl+C to detach — the session keeps running.`);
23990
24213
  await new Promise((resolve) => {
23991
24214
  process.once("SIGINT", () => {
@@ -23998,17 +24221,20 @@ async function runAttach(context, options) {
23998
24221
  function createChatState() {
23999
24222
  return { phase: "idle", pendingPermission: null };
24000
24223
  }
24001
- function isTurnEnd(content) {
24002
- if (content.role !== "session") return false;
24003
- const { t } = content.content.ev;
24004
- return t === "turn-end" || t === "stop";
24224
+ function turnBoundary(content) {
24225
+ if (content.role !== "session") return null;
24226
+ const { role, ev } = content.content;
24227
+ if (role === "agent" && ev.t === "turn-start") return "start";
24228
+ return ev.t === "turn-end" || ev.t === "stop" ? "end" : null;
24005
24229
  }
24006
24230
  function reduceChatState(state, event) {
24007
24231
  switch (event.type) {
24008
24232
  case "message-sent":
24009
24233
  return { phase: "responding", pendingPermission: null };
24010
24234
  case "content": {
24011
- if (!isTurnEnd(event.content)) return state;
24235
+ const boundary = turnBoundary(event.content);
24236
+ if (boundary === "start") return { phase: "responding", pendingPermission: null };
24237
+ if (boundary !== "end") return state;
24012
24238
  if (state.phase === "idle") return state;
24013
24239
  return { phase: "idle", pendingPermission: null };
24014
24240
  }
@@ -24031,6 +24257,27 @@ function interruptMenuChoice(key) {
24031
24257
  if (first === "s") return "stop";
24032
24258
  return "watch";
24033
24259
  }
24260
+ function chatPresentation(state) {
24261
+ if (state.phase === "idle") {
24262
+ return {
24263
+ inputMode: "prompt",
24264
+ status: "● Ready — you can type",
24265
+ prompt: "› "
24266
+ };
24267
+ }
24268
+ if (state.phase === "awaiting-permission") {
24269
+ return {
24270
+ inputMode: "permission",
24271
+ status: "◆ Action required — permission response only",
24272
+ prompt: null
24273
+ };
24274
+ }
24275
+ return {
24276
+ inputMode: "locked",
24277
+ status: "● Agent is responding — input locked · Ctrl+C for options",
24278
+ prompt: null
24279
+ };
24280
+ }
24034
24281
  function describePermissionRequest(request) {
24035
24282
  const summary = summarizeToolArgs(request.arguments ?? {});
24036
24283
  if (summary) return `${request.toolName}(${summary})`;
@@ -24061,6 +24308,106 @@ function resumeHistoryCount(input) {
24061
24308
  if (!input.resuming) return 0;
24062
24309
  return input.since ?? DEFAULT_RESUME_HISTORY;
24063
24310
  }
24311
+ class ChatInputController {
24312
+ constructor(readline, input) {
24313
+ this.readline = readline;
24314
+ this.input = input;
24315
+ this.readline.once("close", this.handleClose);
24316
+ }
24317
+ closed = false;
24318
+ locked = false;
24319
+ rawBeforeLock = false;
24320
+ onInterrupt = null;
24321
+ suspendedDataListeners = [];
24322
+ keyListener = null;
24323
+ resolveKey = null;
24324
+ questionAbort = null;
24325
+ handleClose = () => {
24326
+ this.closed = true;
24327
+ this.cancelQuestion();
24328
+ this.unlock();
24329
+ };
24330
+ handleLockedData = (chunk) => {
24331
+ const value2 = typeof chunk === "string" ? chunk : chunk.toString("utf8");
24332
+ if (value2.includes("")) this.onInterrupt?.();
24333
+ };
24334
+ lock(onInterrupt) {
24335
+ this.onInterrupt = onInterrupt;
24336
+ if (this.locked || this.closed) return;
24337
+ this.cancelQuestion();
24338
+ this.locked = true;
24339
+ this.rawBeforeLock = this.input.isRaw === true;
24340
+ this.readline.pause();
24341
+ this.suspendedDataListeners = this.input.listeners("data");
24342
+ for (const listener of this.suspendedDataListeners) this.input.off("data", listener);
24343
+ if (this.input.isTTY) this.input.setRawMode?.(true);
24344
+ this.input.resume();
24345
+ this.input.on("data", this.handleLockedData);
24346
+ }
24347
+ unlock() {
24348
+ if (!this.locked) return;
24349
+ this.locked = false;
24350
+ this.input.off("data", this.handleLockedData);
24351
+ if (this.keyListener) {
24352
+ this.input.off("data", this.keyListener);
24353
+ this.keyListener = null;
24354
+ const resolve = this.resolveKey;
24355
+ this.resolveKey = null;
24356
+ resolve?.("");
24357
+ }
24358
+ if (this.input.isTTY) this.input.setRawMode?.(this.rawBeforeLock);
24359
+ for (const listener of this.suspendedDataListeners) this.input.on("data", listener);
24360
+ this.suspendedDataListeners = [];
24361
+ this.readline.resume();
24362
+ }
24363
+ ask(prompt) {
24364
+ this.unlock();
24365
+ if (this.closed) return Promise.resolve(null);
24366
+ const abort = new AbortController();
24367
+ this.questionAbort = abort;
24368
+ return new Promise((resolve) => {
24369
+ let settled = false;
24370
+ const finish = (answer) => {
24371
+ if (settled) return;
24372
+ settled = true;
24373
+ this.readline.off("close", onClose);
24374
+ abort.signal.removeEventListener("abort", onAbort);
24375
+ if (this.questionAbort === abort) this.questionAbort = null;
24376
+ resolve(answer);
24377
+ };
24378
+ const onClose = () => finish(null);
24379
+ const onAbort = () => finish(null);
24380
+ this.readline.once("close", onClose);
24381
+ abort.signal.addEventListener("abort", onAbort, { once: true });
24382
+ this.readline.question(prompt, { signal: abort.signal }, (answer) => finish(answer));
24383
+ });
24384
+ }
24385
+ cancelQuestion() {
24386
+ this.questionAbort?.abort();
24387
+ }
24388
+ readKey() {
24389
+ if (!this.locked || this.closed) return Promise.resolve("");
24390
+ this.input.off("data", this.handleLockedData);
24391
+ return new Promise((resolve) => {
24392
+ this.resolveKey = resolve;
24393
+ this.keyListener = (chunk) => {
24394
+ if (!this.keyListener) return;
24395
+ this.input.off("data", this.keyListener);
24396
+ this.keyListener = null;
24397
+ this.resolveKey = null;
24398
+ if (this.locked) this.input.on("data", this.handleLockedData);
24399
+ resolve(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
24400
+ };
24401
+ this.input.on("data", this.keyListener);
24402
+ });
24403
+ }
24404
+ close() {
24405
+ this.cancelQuestion();
24406
+ this.unlock();
24407
+ this.closed = true;
24408
+ this.readline.off("close", this.handleClose);
24409
+ }
24410
+ }
24064
24411
  function readString$1(source, key) {
24065
24412
  const value2 = source?.[key];
24066
24413
  return typeof value2 === "string" && value2 ? value2 : void 0;
@@ -24312,32 +24659,6 @@ async function spawnSession(transport, machineId, directory, agent) {
24312
24659
  }
24313
24660
  return result.sessionId;
24314
24661
  }
24315
- function ask(rl, question, state) {
24316
- if (state.closed) return Promise.resolve(null);
24317
- return new Promise((resolve) => {
24318
- const onClose = () => resolve(null);
24319
- rl.once("close", onClose);
24320
- rl.question(question, (answer) => {
24321
- rl.off("close", onClose);
24322
- resolve(answer);
24323
- });
24324
- });
24325
- }
24326
- function readSingleKey(rl, input) {
24327
- rl.pause();
24328
- const wasRaw = input.isRaw === true;
24329
- if (input.isTTY) input.setRawMode(true);
24330
- input.resume();
24331
- return new Promise((resolve) => {
24332
- const onData = (chunk) => {
24333
- input.off("data", onData);
24334
- if (input.isTTY) input.setRawMode(wasRaw);
24335
- rl.resume();
24336
- resolve(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
24337
- };
24338
- input.on("data", onData);
24339
- });
24340
- }
24341
24662
  async function replyToPermission(transport, sessionId, request, answer) {
24342
24663
  const approved = answer !== "deny";
24343
24664
  await transport.rpc(sessionId, "permission", {
@@ -24414,50 +24735,22 @@ async function runChat(context, options, providedTransport) {
24414
24735
  return withTransport(context, async (transport) => {
24415
24736
  const sessionId = options.sessionId ?? await spawnSession(transport, target.machineId, target.directory, options.agent);
24416
24737
  context.out.raw(`Session ${sessionId}`);
24417
- context.out.raw("While working: Ctrl+C for options (keep watching · detach · stop).");
24738
+ context.out.raw("During a response, typing is locked. Ctrl+C opens response options.");
24418
24739
  context.out.raw("At the prompt: /detach to leave with the agent still running · /exit to leave.\n");
24419
24740
  const chat = new ChatSession();
24741
+ const renderer = createSessionEventRenderer(terminalContentWidth(process.stdout.columns));
24420
24742
  const answered = /* @__PURE__ */ new Set();
24421
24743
  const ownLocalIds = /* @__PURE__ */ new Set();
24422
24744
  const seenThroughSeq = await prerollHistory(
24423
24745
  context,
24424
24746
  transport,
24425
24747
  sessionId,
24426
- resumeHistoryCount({ resuming, ...options.since !== void 0 ? { since: options.since } : {} })
24748
+ resumeHistoryCount({ resuming, ...options.since !== void 0 ? { since: options.since } : {} }),
24749
+ (content) => chat.apply({ type: "content", content }),
24750
+ renderer
24427
24751
  );
24428
- const stopFollowing = followSession({
24429
- context,
24430
- transport,
24431
- sessionId,
24432
- seenThroughSeq,
24433
- ownLocalIds,
24434
- onContent: (content) => {
24435
- printContent(context, content);
24436
- chat.apply({ type: "content", content });
24437
- },
24438
- onUpdate: (update) => {
24439
- const cipher = readSessionAgentState(update, sessionId);
24440
- if (!cipher) return;
24441
- let decoded;
24442
- try {
24443
- decoded = transport.decodePayload(cipher);
24444
- } catch {
24445
- return;
24446
- }
24447
- for (const request of pendingPermissionRequestsFromState(decoded)) {
24448
- if (answered.has(request.requestId)) continue;
24449
- chat.apply({ type: "permission-requested", request });
24450
- }
24451
- }
24452
- });
24453
24752
  const rl = createInterface({ input: process.stdin, output: process.stdout });
24454
- const stdinState = { closed: false };
24455
- rl.once("close", () => {
24456
- stdinState.closed = true;
24457
- });
24458
- rl.on("line", (raw) => {
24459
- if (raw.trim() === DETACH_COMMAND) chat.detach();
24460
- });
24753
+ const input = new ChatInputController(rl, process.stdin);
24461
24754
  let menuOpen = false;
24462
24755
  const onSigint = () => {
24463
24756
  if (interruptDecision(chat.current) === "exit-cli") {
@@ -24469,7 +24762,7 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
24469
24762
  if (menuOpen) return;
24470
24763
  menuOpen = true;
24471
24764
  context.out.raw("\n^C [Enter] keep watching · [d] detach, agent keeps working · [s] stop the response");
24472
- void readSingleKey(rl, process.stdin).then((key) => {
24765
+ void input.readKey().then((key) => {
24473
24766
  menuOpen = false;
24474
24767
  const choice = interruptMenuChoice(key);
24475
24768
  if (choice === "detach") {
@@ -24484,12 +24777,72 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
24484
24777
  context.out.raw("…still working.");
24485
24778
  });
24486
24779
  };
24780
+ const stopFollowing = followSession({
24781
+ context,
24782
+ transport,
24783
+ sessionId,
24784
+ seenThroughSeq,
24785
+ ownLocalIds,
24786
+ renderer,
24787
+ onContent: (content) => {
24788
+ printContent(context, content, renderer);
24789
+ chat.apply({ type: "content", content });
24790
+ if (chat.current.phase === "responding") input.lock(onSigint);
24791
+ },
24792
+ onUpdate: (update) => {
24793
+ const cipher = readSessionAgentState(update, sessionId);
24794
+ if (!cipher) return;
24795
+ let decoded;
24796
+ try {
24797
+ decoded = transport.decodePayload(cipher);
24798
+ } catch {
24799
+ return;
24800
+ }
24801
+ for (const request of pendingPermissionRequestsFromState(decoded)) {
24802
+ if (answered.has(request.requestId)) continue;
24803
+ chat.apply({ type: "permission-requested", request });
24804
+ input.cancelQuestion();
24805
+ }
24806
+ }
24807
+ });
24487
24808
  process.on("SIGINT", onSigint);
24488
24809
  let outcome = "exited";
24489
24810
  try {
24490
24811
  for (; ; ) {
24491
- const line = await ask(rl, "› ", stdinState);
24492
- if (line === null) break;
24812
+ if (chat.current.phase !== "idle") {
24813
+ if (chat.current.phase === "responding") {
24814
+ context.out.line({ style: "thinking", text: chatPresentation(chat.current).status });
24815
+ input.lock(onSigint);
24816
+ }
24817
+ for (; ; ) {
24818
+ const state = await chat.waitForAttention();
24819
+ if (chat.isDetached) break;
24820
+ if (state.phase !== "awaiting-permission" || !state.pendingPermission) break;
24821
+ const request = state.pendingPermission;
24822
+ context.out.line({ style: "tool", text: chatPresentation(state).status });
24823
+ let answer = parsePermissionAnswer(await input.ask(`${permissionPromptText(request)} `) ?? "n");
24824
+ while (answer === "unknown") {
24825
+ answer = parsePermissionAnswer(await input.ask("Please answer y, n, or a. ") ?? "n");
24826
+ }
24827
+ answered.add(request.requestId);
24828
+ await replyToPermission(transport, sessionId, request, answer);
24829
+ chat.apply({ type: "permission-answered" });
24830
+ context.out.line({ style: "thinking", text: chatPresentation(chat.current).status });
24831
+ input.lock(onSigint);
24832
+ }
24833
+ if (chat.isDetached) {
24834
+ outcome = "detached";
24835
+ break;
24836
+ }
24837
+ continue;
24838
+ }
24839
+ const ready = chatPresentation(chat.current);
24840
+ context.out.line({ style: "status", text: ready.status });
24841
+ const line = await input.ask(ready.prompt);
24842
+ if (line === null) {
24843
+ if (chat.current.phase !== "idle") continue;
24844
+ break;
24845
+ }
24493
24846
  const prompt = line.trim();
24494
24847
  if (prompt === "/exit" || prompt === "/quit") break;
24495
24848
  if (prompt === DETACH_COMMAND) {
@@ -24497,30 +24850,14 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
24497
24850
  break;
24498
24851
  }
24499
24852
  if (prompt === "") continue;
24500
- ownLocalIds.add(await sendPrompt(context, sessionId, prompt));
24501
24853
  chat.apply({ type: "message-sent" });
24502
- context.out.line({ style: "thinking", text: "⏳ working — Ctrl+C for options" });
24503
- for (; ; ) {
24504
- const state = await chat.waitForAttention();
24505
- if (chat.isDetached) break;
24506
- if (state.phase !== "awaiting-permission" || !state.pendingPermission) break;
24507
- const request = state.pendingPermission;
24508
- let answer = parsePermissionAnswer(await ask(rl, `${permissionPromptText(request)} `, stdinState) ?? "n");
24509
- while (answer === "unknown") {
24510
- answer = parsePermissionAnswer(await ask(rl, "Please answer y, n, or a. ", stdinState) ?? "n");
24511
- }
24512
- answered.add(request.requestId);
24513
- await replyToPermission(transport, sessionId, request, answer);
24514
- chat.apply({ type: "permission-answered" });
24515
- }
24516
- if (chat.isDetached) {
24517
- outcome = "detached";
24518
- break;
24519
- }
24854
+ input.lock(onSigint);
24855
+ ownLocalIds.add(await sendPrompt(context, sessionId, prompt));
24520
24856
  }
24521
24857
  } finally {
24522
24858
  stopFollowing();
24523
24859
  process.off("SIGINT", onSigint);
24860
+ input.close();
24524
24861
  rl.close();
24525
24862
  }
24526
24863
  context.out.raw(outcome === "detached" ? `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buzzni/saycode-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Drive Saycode sessions across your machines from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {