@buzzni/saycode-cli 0.1.3 → 0.1.5

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 +10 -3
  2. package/index.mjs +580 -269
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -126,6 +126,11 @@ saycode chat -p <프로젝트> --voice-file ./prompt.wav
126
126
  프롬프트에 쌓이지 않습니다. 권한 요청 때만 `◆ Action required — permission response only`로 바뀌고
127
127
  해당 선택 입력을 받습니다. 에이전트 턴이 끝나 `Ready`가 표시된 뒤에만 일반 프롬프트가 열립니다.
128
128
 
129
+ 이전 답변을 보려면 터미널 scrollback 단축키인 **`Shift+Page Up/Down`**을 사용합니다. Mac 노트북
130
+ 키보드에서는 **`Shift+Fn+↑/↓`**입니다. `Page Up`만 누르면 readline/raw 입력으로 전달될 수 있어
131
+ 스크롤되지 않습니다. CLI는 터미널의 기존 scrollback·복사·검색을 보존하기 위해 alternate screen을
132
+ 사용하지 않습니다.
133
+
129
134
  에이전트가 작업 중일 때 **`Ctrl+C`를 누르면 선택지가 뜹니다.**
130
135
  한 글자만 누르면 됩니다 (Enter 불필요). `Ctrl+C`를 한 번 더 누르면 `d`와 같습니다 —
131
136
  반복해서 누르는 건 "빨리 나가자"는 뜻이고, 나가는 건 되돌릴 수 있지만 작업을 죽이는 건
@@ -139,6 +144,8 @@ saycode chat -p <프로젝트> --voice-file ./prompt.wav
139
144
  | `Ctrl+C` (유휴 중) | CLI를 종료합니다 |
140
145
  | `/detach` | 프롬프트에서 치는 `d`의 별칭 |
141
146
  | `/exit` | 대화를 떠납니다 (탐색기에서 왔다면 탐색기로 복귀) |
147
+ | `/model` | 다음 메시지부터 사용할 모델을 선택합니다 |
148
+ | `/effort` | 현재 모델의 effort를 다음 메시지부터 변경합니다 |
142
149
  | `/voice` | 마이크로 다음 프롬프트를 녹음·전사합니다 |
143
150
  | `/voice <audio-path>` | 기존 음성 파일을 다음 프롬프트로 전사합니다 |
144
151
 
@@ -179,11 +186,11 @@ GitHub Actions 가 태그로 발행합니다 (`.github/workflows/publish-saycode
179
186
 
180
187
  ```bash
181
188
  # 1. 버전 올리기 — 데스크탑 앱 버전과 무관합니다
182
- vi cli/manifest.json # "version": "0.1.3"
189
+ vi cli/manifest.json # "version": "0.1.5"
183
190
 
184
191
  # 2. 커밋하고 태그를 밀면 끝
185
- git tag -a saycode-cli-v0.1.3 -m "Release saycode-cli 0.1.3"
186
- git push origin saycode-cli-v0.1.3
192
+ git tag -a saycode-cli-v0.1.5 -m "Release saycode-cli 0.1.5"
193
+ git push origin saycode-cli-v0.1.5
187
194
  ```
188
195
 
189
196
  워크플로는 발행 전에 이 순서로 막습니다:
package/index.mjs CHANGED
@@ -19,7 +19,7 @@ import require$$4$1 from "tls";
19
19
  import require$$0$5 from "stream";
20
20
  import require$$0$3 from "zlib";
21
21
  import { readFile, stat, mkdtemp, access, rm, mkdir, appendFile, writeFile } from "node:fs/promises";
22
- import { spawn } from "node:child_process";
22
+ import { execFile, spawn } from "node:child_process";
23
23
  import { randomUUID } from "node:crypto";
24
24
  function _mergeNamespaces(n, m) {
25
25
  for (var i = 0; i < m.length; i++) {
@@ -40,7 +40,7 @@ function _mergeNamespaces(n, m) {
40
40
  }
41
41
  return Object.freeze(Object.defineProperty(n, Symbol.toStringTag, { value: "Module" }));
42
42
  }
43
- const version = "0.1.3";
43
+ const version = "0.1.5";
44
44
  const manifest = {
45
45
  version
46
46
  };
@@ -294,9 +294,8 @@ In a chat
294
294
  [Enter] keep watching
295
295
  [d] detach — the agent keeps working
296
296
  [s] stop the response
297
- Ctrl+C (while idle) leave the CLI
298
- /detach same as [d], typed at the prompt
299
- /exit leave the chat
297
+ Ctrl+C idle: leave Shift+Page Up/Down: scroll (Mac: Shift+Fn+↑/↓)
298
+ /model · /effort · /voice [path] · /detach · /exit live chat commands
300
299
 
301
300
  Leaving never stops a remote session — it keeps running on the machine.`;
302
301
  function getDefaultExportFromCjs(x) {
@@ -2802,6 +2801,26 @@ function coerceSessionModels(raw) {
2802
2801
  }
2803
2802
  return out.length ? out : void 0;
2804
2803
  }
2804
+ function dedupeByKey(options) {
2805
+ const seen = /* @__PURE__ */ new Set();
2806
+ const out = [];
2807
+ for (const option of options) {
2808
+ if (seen.has(option.key)) continue;
2809
+ seen.add(option.key);
2810
+ out.push(option);
2811
+ }
2812
+ return out;
2813
+ }
2814
+ function buildOpencodeModelOptions(models) {
2815
+ const options = [{ key: DEFAULT_MODEL_KEY, label: "Default" }];
2816
+ if (Array.isArray(models)) {
2817
+ for (const model of models) {
2818
+ if (!model || typeof model.code !== "string" || !model.code) continue;
2819
+ options.push({ key: model.code, label: model.value || model.code });
2820
+ }
2821
+ }
2822
+ return dedupeByKey(options);
2823
+ }
2805
2824
  function hasModelCode(models, code) {
2806
2825
  return Array.isArray(models) && models.some((model) => model?.code === code);
2807
2826
  }
@@ -2809,6 +2828,10 @@ function normalizeOpencodeModel(selected, models) {
2809
2828
  if (!selected || selected === DEFAULT_MODEL_KEY) return DEFAULT_MODEL_KEY;
2810
2829
  return hasModelCode(models, selected) ? selected : DEFAULT_MODEL_KEY;
2811
2830
  }
2831
+ function resolveOpencodeCurrentModel(currentModelCode, models) {
2832
+ if (currentModelCode && hasModelCode(models, currentModelCode)) return currentModelCode;
2833
+ return DEFAULT_MODEL_KEY;
2834
+ }
2812
2835
  const MAX_MAPPING_COUNT = 1e3;
2813
2836
  function buildProjectConversationsPath(projectId) {
2814
2837
  const trimmed = projectId.trim();
@@ -24382,6 +24405,38 @@ function interruptMenuChoice(key) {
24382
24405
  if (first === "s") return "stop";
24383
24406
  return "watch";
24384
24407
  }
24408
+ class ChatSession {
24409
+ state = createChatState();
24410
+ waiters = [];
24411
+ detached = false;
24412
+ get current() {
24413
+ return this.state;
24414
+ }
24415
+ get isDetached() {
24416
+ return this.detached;
24417
+ }
24418
+ detach() {
24419
+ this.detached = true;
24420
+ this.wakeAll();
24421
+ }
24422
+ wakeAll() {
24423
+ const waiting = this.waiters;
24424
+ this.waiters = [];
24425
+ for (const wake of waiting) wake();
24426
+ }
24427
+ apply(event) {
24428
+ const next = reduceChatState(this.state, event);
24429
+ if (next === this.state) return;
24430
+ this.state = next;
24431
+ this.wakeAll();
24432
+ }
24433
+ async waitForAttention() {
24434
+ while (this.state.phase === "responding" && !this.detached) {
24435
+ await new Promise((resolve) => this.waiters.push(resolve));
24436
+ }
24437
+ return this.state;
24438
+ }
24439
+ }
24385
24440
  function chatPresentation(state) {
24386
24441
  if (state.phase === "idle") {
24387
24442
  return {
@@ -24403,6 +24458,213 @@ function chatPresentation(state) {
24403
24458
  prompt: null
24404
24459
  };
24405
24460
  }
24461
+ function chatStartupHints() {
24462
+ return [
24463
+ "During a response, typing is locked. Ctrl+C opens response options.",
24464
+ "Scroll history: Shift+Page Up/Down · Mac laptops: Shift+Fn+↑/↓.",
24465
+ "At the prompt: /model · /effort · /voice to dictate · /detach to leave running · /exit to leave.\n"
24466
+ ];
24467
+ }
24468
+ const claudeEffortOptions = [
24469
+ { key: "low", label: "Low" },
24470
+ { key: "medium", label: "Medium" },
24471
+ { key: "high", label: "High" },
24472
+ { key: "max", label: "Max" }
24473
+ ];
24474
+ const claudeFableEffortOptions = [
24475
+ { key: "low", label: "Low" },
24476
+ { key: "medium", label: "Medium" },
24477
+ { key: "high", label: "High" },
24478
+ { key: "xhigh", label: "Xhigh" },
24479
+ { key: "max", label: "Max" }
24480
+ ];
24481
+ const codexEffortOptions = [
24482
+ { key: "low", label: "Low" },
24483
+ { key: "medium", label: "Medium" },
24484
+ { key: "high", label: "High" },
24485
+ { key: "xhigh", label: "Xhigh" }
24486
+ ];
24487
+ const codexMaxEffortOptions = [
24488
+ ...codexEffortOptions,
24489
+ { key: "max", label: "Max" }
24490
+ ];
24491
+ const codexUltraEffortOptions = [
24492
+ ...codexMaxEffortOptions,
24493
+ { key: "ultra", label: "Ultra" }
24494
+ ];
24495
+ function getEffortOptionsForAgent(agent, model) {
24496
+ if (model === "default") return [];
24497
+ if (agent === "codex") {
24498
+ if (model === "gpt-5.6-sol" || model === "gpt-5.6-terra") return codexUltraEffortOptions;
24499
+ if (model === "gpt-5.6-luna") return codexMaxEffortOptions;
24500
+ return codexEffortOptions;
24501
+ }
24502
+ if (agent === "gemini" || agent === "opencode") return [];
24503
+ if (model === "claude-fable-5") return claudeFableEffortOptions;
24504
+ return claudeEffortOptions;
24505
+ }
24506
+ function defaultEffortForAgent(agent, model) {
24507
+ const options = getEffortOptionsForAgent(agent, model);
24508
+ if (options.length === 0) return null;
24509
+ if (agent === "codex") {
24510
+ if (model === "gpt-5.6-sol") return "low";
24511
+ if (model === "gpt-5.6-terra" || model === "gpt-5.6-luna") return "medium";
24512
+ }
24513
+ return options.some((option) => option.key === "high") ? "high" : options[options.length - 1].key;
24514
+ }
24515
+ const newSessionAgents = [
24516
+ { id: "claude", label: "Claude", provider: "Anthropic" },
24517
+ { id: "codex", label: "Codex", provider: "OpenAI" },
24518
+ { id: "opencode", label: "opencode", provider: "opencode" }
24519
+ ];
24520
+ const claudeModelOptions = [
24521
+ { key: "default", label: "Default" },
24522
+ { key: "claude-fable-5", label: "Fable 5" },
24523
+ { key: "claude-opus-5", label: "Opus 5" },
24524
+ { key: "claude-sonnet-5", label: "Sonnet 5" },
24525
+ { key: "claude-haiku-4-5", label: "Haiku 4.5" }
24526
+ ];
24527
+ const codexModelOptions = [
24528
+ { key: "default", label: "Default" },
24529
+ { key: "gpt-5.6-sol", label: "GPT-5.6 Sol" },
24530
+ { key: "gpt-5.6-terra", label: "GPT-5.6 Terra" },
24531
+ { key: "gpt-5.6-luna", label: "GPT-5.6 Luna" },
24532
+ { key: "gpt-5.5", label: "gpt-5.5" },
24533
+ { key: "gpt-5.4", label: "gpt-5.4" },
24534
+ { key: "gpt-5.3-codex", label: "gpt-5.3-codex" },
24535
+ { key: "gpt-5.2-codex", label: "gpt-5.2-codex" },
24536
+ { key: "gpt-5.1-codex-max", label: "gpt-5.1-codex-max" },
24537
+ { key: "gpt-5.2", label: "gpt-5.2" },
24538
+ { key: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini" }
24539
+ ];
24540
+ const geminiModelOptions = [
24541
+ { key: "gemini-3.1-pro-preview", label: "gemini 3.1 pro" },
24542
+ { key: "gemini-3-flash-preview", label: "gemini 3 flash" },
24543
+ { key: "gemini-3.1-flash-lite-preview", label: "gemini 3.1 flash lite" },
24544
+ { key: "gemini-2.5-pro", label: "gemini 2.5 pro" },
24545
+ { key: "gemini-2.5-flash", label: "gemini 2.5 flash" },
24546
+ { key: "gemini-2.5-flash-lite", label: "gemini 2.5 flash lite" }
24547
+ ];
24548
+ const opencodeModelOptions = [
24549
+ { key: "default", label: "Default" }
24550
+ ];
24551
+ function getModelsForAgent(agent) {
24552
+ if (agent === "codex") return codexModelOptions;
24553
+ if (agent === "gemini") return geminiModelOptions;
24554
+ if (agent === "opencode") return opencodeModelOptions;
24555
+ return claudeModelOptions;
24556
+ }
24557
+ function normalizeModelForAgent(agent, model) {
24558
+ if (agent === "opencode") return model || "default";
24559
+ const models = getModelsForAgent(agent);
24560
+ return models.some((option) => option.key === model) ? model : models[0]?.key ?? "default";
24561
+ }
24562
+ function normalizeModelSelectionOptions(agent, current, patch) {
24563
+ const model = normalizeModelForAgent(agent, patch.model ?? current.model);
24564
+ const efforts = getEffortOptionsForAgent(agent, model);
24565
+ const effort = patch.model !== void 0 ? defaultEffortForAgent(agent, model) : patch.effort !== void 0 && efforts.some((option) => option.key === patch.effort) ? patch.effort : current.effort && efforts.some((option) => option.key === current.effort) ? current.effort : defaultEffortForAgent(agent, model);
24566
+ return { model, effort };
24567
+ }
24568
+ function messageModelOptionsFromSelection(agent, options, availableModels) {
24569
+ if (agent === "opencode") {
24570
+ const model = normalizeOpencodeModel(options.model, availableModels);
24571
+ return { model: model !== "default" ? model : void 0, effort: null };
24572
+ }
24573
+ const normalized = normalizeModelSelectionOptions(agent, options, {});
24574
+ return {
24575
+ model: normalized.model !== "default" ? normalized.model : void 0,
24576
+ effort: normalized.effort
24577
+ };
24578
+ }
24579
+ function configurableAgent$1(value2) {
24580
+ return value2 === "claude" || value2 === "codex" || value2 === "gemini" || value2 === "opencode" ? value2 : null;
24581
+ }
24582
+ function createLiveChatSettings(input) {
24583
+ const agent = configurableAgent$1(input.agent);
24584
+ const models = [...input.models ?? []];
24585
+ if (!agent) return { agent: null, model: "default", effort: null, models };
24586
+ if (agent === "opencode") {
24587
+ const requested = input.model ?? resolveOpencodeCurrentModel(input.currentModelCode ?? void 0, models);
24588
+ return { agent, model: normalizeOpencodeModel(requested, models), effort: null, models };
24589
+ }
24590
+ let selection = normalizeModelSelectionOptions(
24591
+ agent,
24592
+ { model: "default", effort: null },
24593
+ { model: input.model ?? "default" }
24594
+ );
24595
+ if (input.effort) selection = normalizeModelSelectionOptions(agent, selection, { effort: input.effort });
24596
+ return { agent, ...selection, models };
24597
+ }
24598
+ function liveChatModelChoices(settings) {
24599
+ if (!settings.agent) return [];
24600
+ if (settings.agent === "opencode") return buildOpencodeModelOptions(settings.models);
24601
+ return getModelsForAgent(settings.agent);
24602
+ }
24603
+ function liveChatEffortChoices(settings) {
24604
+ if (!settings.agent) return [];
24605
+ return getEffortOptionsForAgent(settings.agent, settings.model);
24606
+ }
24607
+ function updateLiveChatSettings(current, patch) {
24608
+ if (!current.agent) return current;
24609
+ if (current.agent === "opencode") {
24610
+ if (patch.model === void 0) return current;
24611
+ return { ...current, model: normalizeOpencodeModel(patch.model, current.models), effort: null };
24612
+ }
24613
+ let selection = { model: current.model, effort: current.effort };
24614
+ if (patch.model !== void 0) {
24615
+ if (!liveChatModelChoices(current).some((option) => option.key === patch.model)) return current;
24616
+ selection = normalizeModelSelectionOptions(current.agent, selection, { model: patch.model });
24617
+ }
24618
+ if (patch.effort !== void 0) {
24619
+ const efforts = getEffortOptionsForAgent(current.agent, selection.model);
24620
+ if (patch.effort !== null && !efforts.some((option) => option.key === patch.effort)) return current;
24621
+ selection = {
24622
+ ...selection,
24623
+ effort: patch.effort ?? defaultEffortForAgent(current.agent, selection.model)
24624
+ };
24625
+ }
24626
+ return { ...current, ...selection };
24627
+ }
24628
+ function observeLiveChatMessage(current, content) {
24629
+ if (content.role !== "user") return current;
24630
+ const model = typeof content.meta?.model === "string" ? content.meta.model : "default";
24631
+ const effort = typeof content.meta?.effort === "string" ? content.meta.effort : null;
24632
+ return updateLiveChatSettings(current, { model, effort });
24633
+ }
24634
+ function buildLiveChatUserMessage(text2, settings) {
24635
+ const modelOptions = settings.agent ? messageModelOptionsFromSelection(settings.agent, settings, settings.models) : {};
24636
+ return {
24637
+ role: "user",
24638
+ content: { type: "text", text: text2 },
24639
+ meta: {
24640
+ permissionMode: "default",
24641
+ ...modelOptions.model ? { model: modelOptions.model } : {},
24642
+ ...modelOptions.effort ? { effort: modelOptions.effort } : {}
24643
+ }
24644
+ };
24645
+ }
24646
+ function liveChatSettingsFromSessionRows(rows, sessionId, candidateKeys, fallbackAgent) {
24647
+ const row = rows.find((entry) => entry.id === sessionId);
24648
+ const metadata = decryptJsonWithCandidates(row?.metadata, candidateKeys);
24649
+ const agent = agentFromFlavor(typeof metadata?.flavor === "string" ? metadata.flavor : void 0) ?? fallbackAgent;
24650
+ const models = coerceSessionModels(metadata?.models) ?? [];
24651
+ return createLiveChatSettings({
24652
+ agent,
24653
+ models,
24654
+ currentModelCode: typeof metadata?.currentModelCode === "string" ? metadata.currentModelCode : null
24655
+ });
24656
+ }
24657
+ function needsLiveChatModelRefresh(settings) {
24658
+ return settings.agent === "opencode" && settings.models.length === 0;
24659
+ }
24660
+ function refreshLiveChatModelsFromSessionRows(current, rows, sessionId, candidateKeys) {
24661
+ if (!needsLiveChatModelRefresh(current)) return current;
24662
+ const row = rows.find((entry) => entry.id === sessionId);
24663
+ const metadata = decryptJsonWithCandidates(row?.metadata, candidateKeys);
24664
+ const models = coerceSessionModels(metadata?.models);
24665
+ if (!models?.length) return current;
24666
+ return createLiveChatSettings({ ...current, models });
24667
+ }
24406
24668
  function describePermissionRequest(request) {
24407
24669
  const summary = summarizeToolArgs(request.arguments ?? {});
24408
24670
  if (summary) return `${request.toolName}(${summary})`;
@@ -24775,116 +25037,11 @@ async function runProjectConversations(context, query, all) {
24775
25037
  ! Some conversations could not be resolved${payload.warnings.length ? `: ${payload.warnings.join(", ")}` : "."}`);
24776
25038
  }
24777
25039
  }
24778
- const claudeEffortOptions = [
24779
- { key: "low", label: "Low" },
24780
- { key: "medium", label: "Medium" },
24781
- { key: "high", label: "High" },
24782
- { key: "max", label: "Max" }
24783
- ];
24784
- const claudeFableEffortOptions = [
24785
- { key: "low", label: "Low" },
24786
- { key: "medium", label: "Medium" },
24787
- { key: "high", label: "High" },
24788
- { key: "xhigh", label: "Xhigh" },
24789
- { key: "max", label: "Max" }
24790
- ];
24791
- const codexEffortOptions = [
24792
- { key: "low", label: "Low" },
24793
- { key: "medium", label: "Medium" },
24794
- { key: "high", label: "High" },
24795
- { key: "xhigh", label: "Xhigh" }
24796
- ];
24797
- const codexMaxEffortOptions = [
24798
- ...codexEffortOptions,
24799
- { key: "max", label: "Max" }
24800
- ];
24801
- const codexUltraEffortOptions = [
24802
- ...codexMaxEffortOptions,
24803
- { key: "ultra", label: "Ultra" }
24804
- ];
24805
- function getEffortOptionsForAgent(agent, model) {
24806
- if (model === "default") return [];
24807
- if (agent === "codex") {
24808
- if (model === "gpt-5.6-sol" || model === "gpt-5.6-terra") return codexUltraEffortOptions;
24809
- if (model === "gpt-5.6-luna") return codexMaxEffortOptions;
24810
- return codexEffortOptions;
24811
- }
24812
- if (agent === "gemini" || agent === "opencode") return [];
24813
- if (model === "claude-fable-5") return claudeFableEffortOptions;
24814
- return claudeEffortOptions;
24815
- }
24816
- function defaultEffortForAgent(agent, model) {
24817
- const options = getEffortOptionsForAgent(agent, model);
24818
- if (options.length === 0) return null;
24819
- if (agent === "codex") {
24820
- if (model === "gpt-5.6-sol") return "low";
24821
- if (model === "gpt-5.6-terra" || model === "gpt-5.6-luna") return "medium";
24822
- }
24823
- return options.some((option) => option.key === "high") ? "high" : options[options.length - 1].key;
24824
- }
24825
- const newSessionAgents = [
24826
- { id: "claude", label: "Claude", provider: "Anthropic" },
24827
- { id: "codex", label: "Codex", provider: "OpenAI" },
24828
- { id: "opencode", label: "opencode", provider: "opencode" }
24829
- ];
24830
- const claudeModelOptions = [
24831
- { key: "default", label: "Default" },
24832
- { key: "claude-fable-5", label: "Fable 5" },
24833
- { key: "claude-opus-5", label: "Opus 5" },
24834
- { key: "claude-sonnet-5", label: "Sonnet 5" },
24835
- { key: "claude-haiku-4-5", label: "Haiku 4.5" }
24836
- ];
24837
- const codexModelOptions = [
24838
- { key: "default", label: "Default" },
24839
- { key: "gpt-5.6-sol", label: "GPT-5.6 Sol" },
24840
- { key: "gpt-5.6-terra", label: "GPT-5.6 Terra" },
24841
- { key: "gpt-5.6-luna", label: "GPT-5.6 Luna" },
24842
- { key: "gpt-5.5", label: "gpt-5.5" },
24843
- { key: "gpt-5.4", label: "gpt-5.4" },
24844
- { key: "gpt-5.3-codex", label: "gpt-5.3-codex" },
24845
- { key: "gpt-5.2-codex", label: "gpt-5.2-codex" },
24846
- { key: "gpt-5.1-codex-max", label: "gpt-5.1-codex-max" },
24847
- { key: "gpt-5.2", label: "gpt-5.2" },
24848
- { key: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini" }
24849
- ];
24850
- const geminiModelOptions = [
24851
- { key: "gemini-3.1-pro-preview", label: "gemini 3.1 pro" },
24852
- { key: "gemini-3-flash-preview", label: "gemini 3 flash" },
24853
- { key: "gemini-3.1-flash-lite-preview", label: "gemini 3.1 flash lite" },
24854
- { key: "gemini-2.5-pro", label: "gemini 2.5 pro" },
24855
- { key: "gemini-2.5-flash", label: "gemini 2.5 flash" },
24856
- { key: "gemini-2.5-flash-lite", label: "gemini 2.5 flash lite" }
24857
- ];
24858
- const opencodeModelOptions = [
24859
- { key: "default", label: "Default" }
24860
- ];
24861
- function getModelsForAgent(agent) {
24862
- if (agent === "codex") return codexModelOptions;
24863
- if (agent === "gemini") return geminiModelOptions;
24864
- if (agent === "opencode") return opencodeModelOptions;
24865
- return claudeModelOptions;
24866
- }
24867
- function normalizeModelForAgent(agent, model) {
24868
- if (agent === "opencode") return model || "default";
24869
- const models = getModelsForAgent(agent);
24870
- return models.some((option) => option.key === model) ? model : models[0]?.key ?? "default";
24871
- }
24872
- function normalizeModelSelectionOptions(agent, current, patch) {
24873
- const model = normalizeModelForAgent(agent, patch.model ?? current.model);
24874
- const efforts = getEffortOptionsForAgent(agent, model);
24875
- const effort = patch.model !== void 0 ? defaultEffortForAgent(agent, model) : patch.effort !== void 0 && efforts.some((option) => option.key === patch.effort) ? patch.effort : current.effort && efforts.some((option) => option.key === current.effort) ? current.effort : defaultEffortForAgent(agent, model);
24876
- return { model, effort };
24877
- }
24878
- function messageModelOptionsFromSelection(agent, options, availableModels) {
24879
- if (agent === "opencode") {
24880
- const model = normalizeOpencodeModel(options.model, availableModels);
24881
- return { model: model !== "default" ? model : void 0, effort: null };
24882
- }
24883
- const normalized = normalizeModelSelectionOptions(agent, options, {});
24884
- return {
24885
- model: normalized.model !== "default" ? normalized.model : void 0,
24886
- effort: normalized.effort
24887
- };
25040
+ function worktreeTargetMismatch(record2, expected) {
25041
+ if (record2.projectId !== expected.projectId) return "project";
25042
+ if (record2.machineId !== expected.machineId) return "machine";
25043
+ if (expected.path !== void 0 && record2.path !== expected.path) return "path";
25044
+ return null;
24888
25045
  }
24889
25046
  const WORKTREE_NAME_ADJECTIVES = [
24890
25047
  "bright",
@@ -24946,63 +25103,6 @@ function isWorktreePathWithinProjectRoot(projectRoot, worktreePath) {
24946
25103
  if (segments.length < 1 || segments.length > MAX_MANAGED_WORKTREE_DEPTH) return false;
24947
25104
  return segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
24948
25105
  }
24949
- const DEFAULT_CLI_NEW_CONVERSATION_OPTIONS = {
24950
- agent: "claude",
24951
- model: "default",
24952
- effort: null,
24953
- useWorktree: false,
24954
- worktreeName: "",
24955
- worktreeBaseRef: "HEAD",
24956
- files: [],
24957
- voice: false,
24958
- voiceFile: null
24959
- };
24960
- function normalizeNewConversationOptions(current, patch) {
24961
- const agent = patch.agent ?? current.agent;
24962
- const agentChanged = patch.agent !== void 0 && patch.agent !== current.agent;
24963
- const currentSelection = agentChanged ? { model: "default", effort: null } : { model: current.model, effort: current.effort };
24964
- const modelSelection = normalizeModelSelectionOptions(agent, currentSelection, {
24965
- ...patch.model !== void 0 ? { model: patch.model } : {}
24966
- });
24967
- const efforts = getEffortOptionsForAgent(agent, modelSelection.model);
24968
- const requestedEffort = patch.effort !== void 0 ? patch.effort : modelSelection.effort;
24969
- const effort = requestedEffort && efforts.some((option) => option.key === requestedEffort) ? requestedEffort : defaultEffortForAgent(agent, modelSelection.model);
24970
- return {
24971
- agent,
24972
- model: modelSelection.model,
24973
- effort,
24974
- useWorktree: patch.useWorktree ?? current.useWorktree,
24975
- worktreeName: patch.worktreeName !== void 0 ? sanitizeWorktreeName(patch.worktreeName) : current.worktreeName,
24976
- worktreeBaseRef: (patch.worktreeBaseRef ?? current.worktreeBaseRef).trim() || "HEAD",
24977
- files: patch.files ? [...patch.files] : [...current.files],
24978
- voice: patch.voice ?? current.voice,
24979
- voiceFile: patch.voiceFile !== void 0 ? patch.voiceFile : current.voiceFile
24980
- };
24981
- }
24982
- function buildCliUserMessage(text2, options) {
24983
- const modelOptions = messageModelOptionsFromSelection(options.agent, options);
24984
- return {
24985
- role: "user",
24986
- content: { type: "text", text: text2 },
24987
- meta: {
24988
- permissionMode: "default",
24989
- ...modelOptions.model ? { model: modelOptions.model } : {},
24990
- ...modelOptions.effort ? { effort: modelOptions.effort } : {}
24991
- }
24992
- };
24993
- }
24994
- function modelChoices(agent) {
24995
- return getModelsForAgent(agent);
24996
- }
24997
- function effortChoices(agent, model) {
24998
- return getEffortOptionsForAgent(agent, normalizeModelForAgent(agent, model));
24999
- }
25000
- function worktreeTargetMismatch(record2, expected) {
25001
- if (record2.projectId !== expected.projectId) return "project";
25002
- if (record2.machineId !== expected.machineId) return "machine";
25003
- if (expected.path !== void 0 && record2.path !== expected.path) return "path";
25004
- return null;
25005
- }
25006
25106
  async function createWorktreeOnMachine(input) {
25007
25107
  const record2 = await input.client.createProjectWorktree(input.projectId, {
25008
25108
  name: input.name,
@@ -25187,6 +25287,45 @@ function uploadCliAttachment(transport, machineId, workspaceRoot, file, determin
25187
25287
  function prepareCliMessageWithAttachments(text2, files, uploadFile) {
25188
25288
  return preparePersonalChatMessageWithAttachments({ text: text2, files }, { uploadFile });
25189
25289
  }
25290
+ const DEFAULT_CLI_NEW_CONVERSATION_OPTIONS = {
25291
+ agent: "claude",
25292
+ model: "default",
25293
+ effort: null,
25294
+ useWorktree: false,
25295
+ worktreeName: "",
25296
+ worktreeBaseRef: "HEAD",
25297
+ files: [],
25298
+ voice: false,
25299
+ voiceFile: null
25300
+ };
25301
+ function normalizeNewConversationOptions(current, patch) {
25302
+ const agent = patch.agent ?? current.agent;
25303
+ const agentChanged = patch.agent !== void 0 && patch.agent !== current.agent;
25304
+ const currentSelection = agentChanged ? { model: "default", effort: null } : { model: current.model, effort: current.effort };
25305
+ const modelSelection = normalizeModelSelectionOptions(agent, currentSelection, {
25306
+ ...patch.model !== void 0 ? { model: patch.model } : {}
25307
+ });
25308
+ const efforts = getEffortOptionsForAgent(agent, modelSelection.model);
25309
+ const requestedEffort = patch.effort !== void 0 ? patch.effort : modelSelection.effort;
25310
+ const effort = requestedEffort && efforts.some((option) => option.key === requestedEffort) ? requestedEffort : defaultEffortForAgent(agent, modelSelection.model);
25311
+ return {
25312
+ agent,
25313
+ model: modelSelection.model,
25314
+ effort,
25315
+ useWorktree: patch.useWorktree ?? current.useWorktree,
25316
+ worktreeName: patch.worktreeName !== void 0 ? sanitizeWorktreeName(patch.worktreeName) : current.worktreeName,
25317
+ worktreeBaseRef: (patch.worktreeBaseRef ?? current.worktreeBaseRef).trim() || "HEAD",
25318
+ files: patch.files ? [...patch.files] : [...current.files],
25319
+ voice: patch.voice ?? current.voice,
25320
+ voiceFile: patch.voiceFile !== void 0 ? patch.voiceFile : current.voiceFile
25321
+ };
25322
+ }
25323
+ function modelChoices(agent) {
25324
+ return getModelsForAgent(agent);
25325
+ }
25326
+ function effortChoices(agent, model) {
25327
+ return getEffortOptionsForAgent(agent, normalizeModelForAgent(agent, model));
25328
+ }
25190
25329
  function choiceIndex(answer, length, currentIndex) {
25191
25330
  const trimmed = answer?.trim() ?? "";
25192
25331
  if (!trimmed) return Math.max(0, currentIndex);
@@ -25250,26 +25389,87 @@ function resolveTranscriptionOutcome(text2) {
25250
25389
  if (!cleaned) return { errorKind: "no-speech" };
25251
25390
  return { transcript: cleaned };
25252
25391
  }
25253
- function recorderSpec(platform2, available) {
25392
+ function findDefaultInput(value2) {
25393
+ if (Array.isArray(value2)) {
25394
+ for (const entry of value2) {
25395
+ const found = findDefaultInput(entry);
25396
+ if (found) return found;
25397
+ }
25398
+ return null;
25399
+ }
25400
+ if (!value2 || typeof value2 !== "object") return null;
25401
+ const record2 = value2;
25402
+ if (record2.coreaudio_default_audio_input_device === "spaudio_yes") {
25403
+ return typeof record2._name === "string" ? record2._name : null;
25404
+ }
25405
+ return findDefaultInput(Object.values(record2));
25406
+ }
25407
+ function defaultMacAudioInput(profile) {
25408
+ return findDefaultInput(profile);
25409
+ }
25410
+ async function lookupMacAudioInput() {
25411
+ return new Promise((resolve) => {
25412
+ execFile(
25413
+ "/usr/sbin/system_profiler",
25414
+ ["SPAudioDataType", "-json"],
25415
+ { encoding: "utf8", timeout: 5e3 },
25416
+ (error, stdout) => {
25417
+ if (error) {
25418
+ resolve(null);
25419
+ return;
25420
+ }
25421
+ try {
25422
+ resolve(defaultMacAudioInput(JSON.parse(stdout)));
25423
+ } catch {
25424
+ resolve(null);
25425
+ }
25426
+ }
25427
+ );
25428
+ });
25429
+ }
25430
+ function createMacAudioInputResolver(lookup2 = lookupMacAudioInput) {
25431
+ let cached = null;
25432
+ return () => {
25433
+ cached ??= lookup2().catch(() => null);
25434
+ return cached;
25435
+ };
25436
+ }
25437
+ const resolveMacAudioInput = createMacAudioInputResolver();
25438
+ function ffmpegRecorderSpec(platform2, macInput) {
25439
+ if (platform2 === "darwin") {
25440
+ return {
25441
+ command: "ffmpeg",
25442
+ args: (path) => ["-nostdin", "-loglevel", "error", "-f", "avfoundation", "-i", `:${macInput}`, "-ac", "1", "-ar", "16000", "-y", path]
25443
+ };
25444
+ }
25445
+ return {
25446
+ command: "ffmpeg",
25447
+ args: (path) => ["-nostdin", "-loglevel", "error", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", "-y", path]
25448
+ };
25449
+ }
25450
+ function recorderSpecs(platform2, available, macInput = "0") {
25254
25451
  if (platform2 === "linux" && available.has("arecord")) {
25255
- return { command: "arecord", args: (path) => ["-q", "-f", "S16_LE", "-r", "16000", "-c", "1", path] };
25452
+ return [{ command: "arecord", args: (path) => ["-q", "-f", "S16_LE", "-r", "16000", "-c", "1", path] }];
25256
25453
  }
25257
25454
  if (available.has("rec")) {
25258
- return { command: "rec", args: (path) => ["-q", "-c", "1", "-r", "16000", path] };
25455
+ return [{ command: "rec", args: (path) => ["-q", "-c", "1", "-r", "16000", path] }];
25259
25456
  }
25260
25457
  if (available.has("ffmpeg")) {
25261
- if (platform2 === "darwin") {
25262
- return {
25263
- command: "ffmpeg",
25264
- args: (path) => ["-nostdin", "-loglevel", "error", "-f", "avfoundation", "-i", ":0", "-ac", "1", "-ar", "16000", "-y", path]
25265
- };
25458
+ const preferred = ffmpegRecorderSpec(platform2, macInput);
25459
+ return platform2 === "darwin" && macInput !== "0" ? [preferred, ffmpegRecorderSpec(platform2, "0")] : [preferred];
25460
+ }
25461
+ return [];
25462
+ }
25463
+ async function startRecorderWithFallback(specs, start) {
25464
+ let lastError = new Error("No recorder candidate was provided.");
25465
+ for (const spec of specs) {
25466
+ try {
25467
+ return await start(spec);
25468
+ } catch (error) {
25469
+ lastError = error;
25266
25470
  }
25267
- return {
25268
- command: "ffmpeg",
25269
- args: (path) => ["-nostdin", "-loglevel", "error", "-f", "pulse", "-i", "default", "-ac", "1", "-ar", "16000", "-y", path]
25270
- };
25271
25471
  }
25272
- return null;
25472
+ throw lastError;
25273
25473
  }
25274
25474
  async function installedCommands(env) {
25275
25475
  const result = /* @__PURE__ */ new Set();
@@ -25304,6 +25504,41 @@ function waitForSpawn(child) {
25304
25504
  child.once("error", onError);
25305
25505
  });
25306
25506
  }
25507
+ function waitForRecorderStartup(child) {
25508
+ return new Promise((resolve, reject) => {
25509
+ if (child.exitCode !== null || child.signalCode !== null) {
25510
+ reject(new Error(`Recorder exited during startup (${child.exitCode ?? child.signalCode}).`));
25511
+ return;
25512
+ }
25513
+ let stderr = "";
25514
+ const timer = setTimeout(() => finish(), 300);
25515
+ const finish = (error) => {
25516
+ clearTimeout(timer);
25517
+ child.stderr?.off("data", onData);
25518
+ child.off("close", onClose);
25519
+ child.off("error", onError);
25520
+ if (error) reject(error);
25521
+ else resolve();
25522
+ };
25523
+ const fail = (message) => {
25524
+ child.kill("SIGTERM");
25525
+ finish(new Error(message));
25526
+ };
25527
+ const onData = (chunk) => {
25528
+ stderr = `${stderr}${chunk.toString()}`.slice(-4e3);
25529
+ if (/device not found|failed to create av capture input device|error opening input/i.test(stderr)) {
25530
+ fail(stderr.trim() || "Recorder failed to open its input device.");
25531
+ }
25532
+ };
25533
+ const onClose = (code, signal) => {
25534
+ finish(new Error(stderr.trim() || `Recorder exited during startup (${code ?? signal ?? "unknown"}).`));
25535
+ };
25536
+ const onError = (error) => finish(error);
25537
+ child.stderr?.on("data", onData);
25538
+ child.once("close", onClose);
25539
+ child.once("error", onError);
25540
+ });
25541
+ }
25307
25542
  function waitForClose(child) {
25308
25543
  return new Promise((resolve) => {
25309
25544
  if (child.exitCode !== null || child.signalCode !== null) {
@@ -25322,16 +25557,19 @@ function waitForClose(child) {
25322
25557
  }
25323
25558
  async function recordCliVoice(waitForStop, input = {}) {
25324
25559
  const platform2 = input.platform ?? process.platform;
25325
- const spec = recorderSpec(platform2, await installedCommands(input.env ?? process.env));
25326
- if (!spec) {
25327
- throw new Error("No supported microphone recorder was found. Install ffmpeg/SoX/arecord or use `--voice-file <audio>` instead.");
25328
- }
25560
+ const macInput = platform2 === "darwin" ? await resolveMacAudioInput() : null;
25561
+ const specs = recorderSpecs(platform2, await installedCommands(input.env ?? process.env), macInput ?? "0");
25562
+ if (specs.length === 0) throw new Error("No supported microphone recorder was found. Install ffmpeg/SoX/arecord or use `--voice-file <audio>` instead.");
25329
25563
  const directory = await mkdtemp(join(tmpdir(), "saycode-voice-"));
25330
25564
  const path = join(directory, "prompt.wav");
25331
25565
  const cleanup = () => rm(directory, { recursive: true, force: true });
25332
25566
  try {
25333
- const child = spawn(spec.command, spec.args(path), { stdio: ["ignore", "ignore", "pipe"] });
25334
- await waitForSpawn(child);
25567
+ const child = await startRecorderWithFallback(specs, async (spec) => {
25568
+ const candidate = spawn(spec.command, spec.args(path), { stdio: ["ignore", "ignore", "pipe"] });
25569
+ await waitForSpawn(candidate);
25570
+ if (specs.length > 1) await waitForRecorderStartup(candidate);
25571
+ return candidate;
25572
+ });
25335
25573
  await waitForStop();
25336
25574
  child.kill("SIGINT");
25337
25575
  await waitForClose(child);
@@ -25935,6 +26173,92 @@ async function flushCliUsageAnalytics() {
25935
26173
  if (!current) return;
25936
26174
  await flushUsageAnalyticsWithin(() => current.flush(), CLI_USAGE_ANALYTICS_FLUSH_TIMEOUT_MS);
25937
26175
  }
26176
+ async function sendCliChatPrompt(context, sessionId, text2, settings) {
26177
+ const content = buildLiveChatUserMessage(text2, settings);
26178
+ const key = new Uint8Array(Buffer.from(context.syncCredential.secret, "base64"));
26179
+ const localId = `saycode-cli-${process.pid}-${Math.random().toString(36).slice(2)}`;
26180
+ await context.rest.sendMessage(sessionId, localId, encodeMessageContent(content, key));
26181
+ return localId;
26182
+ }
26183
+ class ChatStatusController {
26184
+ constructor(input, onInterrupt, output) {
26185
+ this.input = input;
26186
+ this.onInterrupt = onInterrupt;
26187
+ this.output = output;
26188
+ }
26189
+ shownInputMode = null;
26190
+ apply(chat, event) {
26191
+ chat.apply(event);
26192
+ this.sync(chat.current);
26193
+ }
26194
+ sync(state) {
26195
+ const presentation = chatPresentation(state);
26196
+ const previousInputMode = this.shownInputMode;
26197
+ if (presentation.inputMode === "locked") this.input.lock(this.onInterrupt);
26198
+ if (presentation.inputMode !== "locked") {
26199
+ if (presentation.inputMode === "permission" && previousInputMode === "prompt") {
26200
+ this.input.cancelQuestion();
26201
+ }
26202
+ this.input.unlock();
26203
+ }
26204
+ if (presentation.inputMode === previousInputMode) return;
26205
+ this.shownInputMode = presentation.inputMode;
26206
+ this.output(presentation);
26207
+ }
26208
+ }
26209
+ async function choose(label, choices, selected, ask) {
26210
+ if (choices.length === 0) return null;
26211
+ const currentIndex = Math.max(0, choices.findIndex((entry) => entry.key === selected));
26212
+ const index = choiceIndex(await ask(choiceQuestion(label, choices, currentIndex)), choices.length, currentIndex);
26213
+ return choices[index]?.key ?? null;
26214
+ }
26215
+ async function promptLiveChatModel(current, ask) {
26216
+ const model = await choose("Model", liveChatModelChoices(current), current.model, ask);
26217
+ if (!model) return current;
26218
+ let settings = updateLiveChatSettings(current, { model });
26219
+ const effort = await choose("Effort", liveChatEffortChoices(settings), settings.effort, ask);
26220
+ if (effort) settings = updateLiveChatSettings(settings, { effort });
26221
+ return settings;
26222
+ }
26223
+ async function promptLiveChatEffort(current, ask) {
26224
+ const effort = await choose("Effort", liveChatEffortChoices(current), current.effort, ask);
26225
+ return effort ? updateLiveChatSettings(current, { effort }) : current;
26226
+ }
26227
+ function liveChatSettingsLabel(settings) {
26228
+ const model = liveChatModelChoices(settings).find((choice) => choice.key === settings.model)?.label ?? settings.model;
26229
+ const effort = liveChatEffortChoices(settings).find((choice) => choice.key === settings.effort)?.label;
26230
+ return effort ? `${model} · ${effort}` : model;
26231
+ }
26232
+ function parseVoiceChatCommand(prompt) {
26233
+ if (prompt !== "/voice" && !prompt.startsWith("/voice ")) return null;
26234
+ return { voiceFile: prompt.slice("/voice".length).trim() || null };
26235
+ }
26236
+ async function handleLiveChatCommand(prompt, current, ask) {
26237
+ if (prompt !== "/model" && prompt !== "/effort") return { handled: false, settings: current };
26238
+ if (!current.agent) {
26239
+ return {
26240
+ handled: true,
26241
+ settings: current,
26242
+ message: "Model controls are not available for this session agent."
26243
+ };
26244
+ }
26245
+ if (prompt === "/effort" && current.model === "default") {
26246
+ return {
26247
+ handled: true,
26248
+ settings: current,
26249
+ message: "Choose a model with /model before selecting effort."
26250
+ };
26251
+ }
26252
+ if (prompt === "/effort" && liveChatEffortChoices(current).length === 0) {
26253
+ return {
26254
+ handled: true,
26255
+ settings: current,
26256
+ message: "Effort controls are not available for the current model."
26257
+ };
26258
+ }
26259
+ const settings = prompt === "/model" ? await promptLiveChatModel(current, ask) : await promptLiveChatEffort(current, ask);
26260
+ return { handled: true, settings, message: `Next message: ${liveChatSettingsLabel(settings)}` };
26261
+ }
25938
26262
  const DETACH_COMMAND = "/detach";
25939
26263
  async function spawnSession(transport, machineId, directory, agent) {
25940
26264
  const result = await transport.rpc(machineId, "spawn-happy-session", { directory, agent }, 6e4);
@@ -25963,47 +26287,6 @@ async function replyToPermission(transport, sessionId, request, answer) {
25963
26287
  ...answer === "approve-always" ? { mode: "bypassPermissions" } : {}
25964
26288
  });
25965
26289
  }
25966
- async function sendPrompt(context, sessionId, text2, options) {
25967
- const content = options ? buildCliUserMessage(text2, options) : { role: "user", content: { type: "text", text: text2 }, meta: { permissionMode: "default" } };
25968
- const key = new Uint8Array(Buffer.from(context.syncCredential.secret, "base64"));
25969
- const localId = `saycode-cli-${process.pid}-${Math.random().toString(36).slice(2)}`;
25970
- await context.rest.sendMessage(sessionId, localId, encodeMessageContent(content, key));
25971
- return localId;
25972
- }
25973
- class ChatSession {
25974
- state = createChatState();
25975
- waiters = [];
25976
- detached = false;
25977
- get current() {
25978
- return this.state;
25979
- }
25980
- get isDetached() {
25981
- return this.detached;
25982
- }
25983
- /** Leave without touching the remote turn — it keeps running on the machine. */
25984
- detach() {
25985
- this.detached = true;
25986
- this.wakeAll();
25987
- }
25988
- wakeAll() {
25989
- const waiting = this.waiters;
25990
- this.waiters = [];
25991
- for (const wake of waiting) wake();
25992
- }
25993
- apply(event) {
25994
- const next = reduceChatState(this.state, event);
25995
- if (next === this.state) return;
25996
- this.state = next;
25997
- this.wakeAll();
25998
- }
25999
- /** Resolves when the turn ends, a permission needs an answer, or on detach. */
26000
- async waitForAttention() {
26001
- while (this.state.phase === "responding" && !this.detached) {
26002
- await new Promise((resolve) => this.waiters.push(resolve));
26003
- }
26004
- return this.state;
26005
- }
26006
- }
26007
26290
  async function resolveSpawnTarget(context, options) {
26008
26291
  if (options.project) {
26009
26292
  const project = await resolveProject(context, options.project);
@@ -26079,8 +26362,13 @@ async function runChat(context, options, providedTransport) {
26079
26362
  }
26080
26363
  }
26081
26364
  context.out.raw(`Session ${sessionId}`);
26082
- context.out.raw("During a response, typing is locked. Ctrl+C opens response options.");
26083
- context.out.raw("At the prompt: /voice to dictate · /detach to leave with the agent still running · /exit to leave.\n");
26365
+ for (const hint of chatStartupHints()) context.out.raw(hint);
26366
+ let liveSettings = newConversationOptions ? createLiveChatSettings(newConversationOptions) : liveChatSettingsFromSessionRows(
26367
+ await context.rest.listSessions().catch(() => []),
26368
+ sessionId,
26369
+ [decodeBase64(context.syncCredential.secret)],
26370
+ options.agent
26371
+ );
26084
26372
  const chat = new ChatSession();
26085
26373
  const renderer = createSessionEventRenderer(terminalContentWidth(process.stdout.columns));
26086
26374
  const answered = /* @__PURE__ */ new Set();
@@ -26092,7 +26380,10 @@ async function runChat(context, options, providedTransport) {
26092
26380
  transport,
26093
26381
  sessionId,
26094
26382
  resumeHistoryCount({ resuming, ...options.since !== void 0 ? { since: options.since } : {} }),
26095
- (content) => chat.apply({ type: "content", content }),
26383
+ (content) => {
26384
+ chat.apply({ type: "content", content });
26385
+ liveSettings = observeLiveChatMessage(liveSettings, content);
26386
+ },
26096
26387
  renderer
26097
26388
  );
26098
26389
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -26123,6 +26414,11 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
26123
26414
  context.out.raw("…still working.");
26124
26415
  });
26125
26416
  };
26417
+ const status = new ChatStatusController(input, onSigint, (presentation) => {
26418
+ const style = presentation.inputMode === "prompt" ? "status" : presentation.inputMode === "permission" ? "tool" : "thinking";
26419
+ context.out.line({ style, text: presentation.status });
26420
+ });
26421
+ status.sync(chat.current);
26126
26422
  const stopFollowing = followSession({
26127
26423
  context,
26128
26424
  transport,
@@ -26132,8 +26428,8 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
26132
26428
  renderer,
26133
26429
  onContent: (content) => {
26134
26430
  printContent(context, content, renderer);
26135
- chat.apply({ type: "content", content });
26136
- if (chat.current.phase === "responding") input.lock(onSigint);
26431
+ status.apply(chat, { type: "content", content });
26432
+ liveSettings = observeLiveChatMessage(liveSettings, content);
26137
26433
  },
26138
26434
  onUpdate: (update) => {
26139
26435
  const cipher = readSessionAgentState(update, sessionId);
@@ -26146,8 +26442,7 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
26146
26442
  }
26147
26443
  for (const request of pendingPermissionRequestsFromState(decoded)) {
26148
26444
  if (answered.has(request.requestId)) continue;
26149
- chat.apply({ type: "permission-requested", request });
26150
- input.cancelQuestion();
26445
+ status.apply(chat, { type: "permission-requested", request });
26151
26446
  }
26152
26447
  }
26153
26448
  });
@@ -26156,25 +26451,18 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
26156
26451
  try {
26157
26452
  for (; ; ) {
26158
26453
  if (chat.current.phase !== "idle") {
26159
- if (chat.current.phase === "responding") {
26160
- context.out.line({ style: "thinking", text: chatPresentation(chat.current).status });
26161
- input.lock(onSigint);
26162
- }
26163
26454
  for (; ; ) {
26164
26455
  const state = await chat.waitForAttention();
26165
26456
  if (chat.isDetached) break;
26166
26457
  if (state.phase !== "awaiting-permission" || !state.pendingPermission) break;
26167
26458
  const request = state.pendingPermission;
26168
- context.out.line({ style: "tool", text: chatPresentation(state).status });
26169
26459
  let answer = parsePermissionAnswer(await input.ask(`${permissionPromptText(request)} `) ?? "n");
26170
26460
  while (answer === "unknown") {
26171
26461
  answer = parsePermissionAnswer(await input.ask("Please answer y, n, or a. ") ?? "n");
26172
26462
  }
26173
26463
  answered.add(request.requestId);
26174
26464
  await replyToPermission(transport, sessionId, request, answer);
26175
- chat.apply({ type: "permission-answered" });
26176
- context.out.line({ style: "thinking", text: chatPresentation(chat.current).status });
26177
- input.lock(onSigint);
26465
+ status.apply(chat, { type: "permission-answered" });
26178
26466
  }
26179
26467
  if (chat.isDetached) {
26180
26468
  outcome = "detached";
@@ -26183,7 +26471,7 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
26183
26471
  continue;
26184
26472
  }
26185
26473
  const ready = chatPresentation(chat.current);
26186
- context.out.line({ style: "status", text: ready.status });
26474
+ status.sync(chat.current);
26187
26475
  let prompt = "";
26188
26476
  if (pendingVoice) {
26189
26477
  const voiceFile = typeof pendingVoice === "string" ? pendingVoice : null;
@@ -26210,10 +26498,28 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
26210
26498
  outcome = "detached";
26211
26499
  break;
26212
26500
  }
26213
- if (prompt === "/voice" || prompt.startsWith("/voice ")) {
26214
- const voiceFile = prompt.slice("/voice".length).trim() || null;
26501
+ if (prompt === "/model" && needsLiveChatModelRefresh(liveSettings)) {
26502
+ liveSettings = refreshLiveChatModelsFromSessionRows(
26503
+ liveSettings,
26504
+ await context.rest.listSessions().catch(() => []),
26505
+ sessionId,
26506
+ [decodeBase64(context.syncCredential.secret)]
26507
+ );
26508
+ }
26509
+ const liveCommand = await handleLiveChatCommand(
26510
+ prompt,
26511
+ liveSettings,
26512
+ (question) => input.ask(question)
26513
+ );
26514
+ if (liveCommand.handled) {
26515
+ liveSettings = liveCommand.settings;
26516
+ if (liveCommand.message) context.out.raw(liveCommand.message);
26517
+ continue;
26518
+ }
26519
+ const voiceCommand = parseVoiceChatCommand(prompt);
26520
+ if (voiceCommand) {
26215
26521
  try {
26216
- prompt = await captureCliVoicePrompt(context, voiceFile, async () => {
26522
+ prompt = await captureCliVoicePrompt(context, voiceCommand.voiceFile, async () => {
26217
26523
  await input.ask("Press Enter to stop recording. ");
26218
26524
  });
26219
26525
  context.out.raw(`Transcript: ${prompt}`);
@@ -26235,9 +26541,8 @@ Leaving. Reattach with \`saycode chat -s ${sessionId}\`.`);
26235
26541
  }
26236
26542
  }
26237
26543
  if (prompt === "") continue;
26238
- chat.apply({ type: "message-sent" });
26239
- input.lock(onSigint);
26240
- ownLocalIds.add(await sendPrompt(context, sessionId, prompt, newConversationOptions));
26544
+ status.apply(chat, { type: "message-sent" });
26545
+ ownLocalIds.add(await sendCliChatPrompt(context, sessionId, prompt, liveSettings));
26241
26546
  await recordCliUsage("message_sent");
26242
26547
  }
26243
26548
  } finally {
@@ -26264,6 +26569,10 @@ const ARROW_UP = `${ESC}[A`;
26264
26569
  const ARROW_DOWN = `${ESC}[B`;
26265
26570
  const ARROW_RIGHT = `${ESC}[C`;
26266
26571
  const ARROW_LEFT = `${ESC}[D`;
26572
+ const PAGE_UP = `${ESC}[5~`;
26573
+ const PAGE_DOWN = `${ESC}[6~`;
26574
+ const HOME_KEYS = /* @__PURE__ */ new Set([`${ESC}[H`, `${ESC}OH`, `${ESC}[1~`]);
26575
+ const END_KEYS = /* @__PURE__ */ new Set([`${ESC}[F`, `${ESC}OF`, `${ESC}[4~`]);
26267
26576
  function isPrintable(key) {
26268
26577
  if (key.length !== 1) return false;
26269
26578
  const code = key.charCodeAt(0);
@@ -26273,6 +26582,10 @@ function keyToAction(key, filtering) {
26273
26582
  if (key === CTRL_C) return { type: "quit" };
26274
26583
  if (key === ARROW_UP) return { type: "up" };
26275
26584
  if (key === ARROW_DOWN) return { type: "down" };
26585
+ if (key === PAGE_UP) return { type: "page-up" };
26586
+ if (key === PAGE_DOWN) return { type: "page-down" };
26587
+ if (HOME_KEYS.has(key)) return { type: "first" };
26588
+ if (END_KEYS.has(key)) return { type: "last" };
26276
26589
  if (filtering) {
26277
26590
  if (key === "\r" || key === "\n") return { type: "filter-commit" };
26278
26591
  if (key === ESC) return { type: "filter-cancel" };
@@ -26388,19 +26701,9 @@ function startKeyboard(input, hooks = process) {
26388
26701
  restore
26389
26702
  };
26390
26703
  }
26704
+ const KEY_SEQUENCE = /\u001b(?:\[[0-?]*[ -/]*[@-~]|O[\s\S])|[\s\S]/g;
26391
26705
  function splitKeys(chunk) {
26392
- const keys = [];
26393
- let index = 0;
26394
- while (index < chunk.length) {
26395
- if (chunk[index] === "\x1B" && chunk[index + 1] === "[" && index + 2 < chunk.length) {
26396
- keys.push(chunk.slice(index, index + 3));
26397
- index += 3;
26398
- continue;
26399
- }
26400
- keys.push(chunk[index]);
26401
- index += 1;
26402
- }
26403
- return keys;
26706
+ return chunk.match(KEY_SEQUENCE) ?? [];
26404
26707
  }
26405
26708
  const STATUS_MARKER = {
26406
26709
  working: "●",
@@ -26525,7 +26828,7 @@ function clampCursor(state) {
26525
26828
  const max = Math.max(0, visibleItems(state).length - 1);
26526
26829
  return state.cursor > max ? { ...state, cursor: 0 } : state;
26527
26830
  }
26528
- function applyAction(state, action) {
26831
+ function applyAction(state, action, pageSize = 1) {
26529
26832
  switch (action.type) {
26530
26833
  case "items-loaded":
26531
26834
  return clampCursor({ ...state, items: action.items, loading: false });
@@ -26535,6 +26838,14 @@ function applyAction(state, action) {
26535
26838
  return { ...state, cursor: Math.max(0, state.cursor - 1) };
26536
26839
  case "down":
26537
26840
  return { ...state, cursor: Math.min(Math.max(0, visibleItems(state).length - 1), state.cursor + 1) };
26841
+ case "page-up":
26842
+ return { ...state, cursor: Math.max(0, state.cursor - Math.max(1, Math.floor(pageSize))) };
26843
+ case "page-down":
26844
+ return { ...state, cursor: Math.min(Math.max(0, visibleItems(state).length - 1), state.cursor + Math.max(1, Math.floor(pageSize))) };
26845
+ case "first":
26846
+ return { ...state, cursor: 0 };
26847
+ case "last":
26848
+ return { ...state, cursor: Math.max(0, visibleItems(state).length - 1) };
26538
26849
  case "select": {
26539
26850
  const chosen = visibleItems(state)[state.cursor];
26540
26851
  if (!chosen) return state;
@@ -26677,7 +26988,7 @@ function paint(context, state) {
26677
26988
  out.push(line.selected ? `\x1B[7m${line.text}\x1B[0m` : line.text);
26678
26989
  }
26679
26990
  out.push("");
26680
- out.push("↑/↓ move · Enter open · Esc back · / filter · r refresh · q quit");
26991
+ out.push("↑/↓ move · PgUp/PgDn page · Home/End jump · Enter open · Esc back · / filter · r refresh · q quit");
26681
26992
  context.out.raw(out.join("\n"));
26682
26993
  }
26683
26994
  async function runBrowse(context) {
@@ -26719,7 +27030,7 @@ async function runBrowse(context) {
26719
27030
  }
26720
27031
  const key = pending.shift();
26721
27032
  if (key === void 0) continue;
26722
- state = applyAction(state, keyToAction(key, state.filtering));
27033
+ state = applyAction(state, keyToAction(key, state.filtering), screenHeight());
26723
27034
  if (state.quit) break;
26724
27035
  if (state.done) {
26725
27036
  const chosen = state.done;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buzzni/saycode-cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Drive Saycode sessions across your machines from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {