diff --git a/.changeset/deepseek-blue-theme.md b/.changeset/deepseek-blue-theme.md new file mode 100644 index 00000000..2a4c391a --- /dev/null +++ b/.changeset/deepseek-blue-theme.md @@ -0,0 +1,15 @@ +--- +'codsh-cli': minor +'codsh-bundle': minor +--- + +The terminal UI is painted in DeepSeek blue (#4D6BFE), and `/theme` switches themes the way Grok CLI does. Arrows preview each theme live, `Enter` saves it and `Esc` reverts. `/theme ` sets a theme directly. + +Themes: +- `auto` (the default) follows the terminal's light or dark background. +- `deepseek` and `deepseek-light` are the two DeepSeek themes. +- `terminal` paints only your terminal's own sixteen colours, with no backgrounds. + +The choice is saved in `$DSH_HOME/code-cli-ui.json` without touching `/ui`'s setting. `CODSH_THEME=` overrides it for one launch. + +The cursor takes the theme's accent (OSC 12) and gets its colour back on exit. History already on screen is repainted in the new theme. The accent, the thinking rail, headings, links and the person's message band all change colour. Semantic colours stay red, green and amber. diff --git a/.scratch/grok-surface-roadmap/issues/01-turn-navigation.md b/.scratch/grok-surface-roadmap/issues/01-turn-navigation.md deleted file mode 100644 index 0f684c9a..00000000 --- a/.scratch/grok-surface-roadmap/issues/01-turn-navigation.md +++ /dev/null @@ -1,30 +0,0 @@ -# 01 — Semantic turn navigation - -Status: completed - -Blocked by: none - -## Objective - -Let an interactive reader move between real user turns with `Shift+Left`, -`Shift+Right`, and `/jump`, including reversible selector previews. - -## Change intent - -- Add a public Screen/TerminalConsole turn-navigation seam over existing prompt descriptors. -- Decode legacy and Kitty modified arrows into previous/next-turn keys. -- Register `/jump [N]`; filterable selection previews a turn, Enter commits, Esc restores. -- Count only `source.kind === "user"` prompts; keep pipe mode unchanged. - -## Verification - -- RED/GREEN: keys, selector preview, Screen turn/trim/resize/clear tests. -- PTY: direct shortcut, `/jump` commit, `/jump` cancel. -- `pnpm run typecheck`. - -## Acceptance - -- [x] Turn ordinals remain stable across folding, rewrap, trimming, clear, and resume. -- [x] Preview cancellation restores the exact original viewport. -- [x] Boundary navigation is a safe no-op with feedback. -- [x] Docs, alignment entry, minor changeset, and scoped commit exist. diff --git a/.scratch/grok-surface-roadmap/issues/02-prompt-top-anchor.md b/.scratch/grok-surface-roadmap/issues/02-prompt-top-anchor.md deleted file mode 100644 index 3c897f3e..00000000 --- a/.scratch/grok-surface-roadmap/issues/02-prompt-top-anchor.md +++ /dev/null @@ -1,30 +0,0 @@ -# 02 — Prompt-top anchoring - -Status: completed - -Blocked by: 01 - -## Objective - -Place a newly submitted real prompt at the viewport top while reply rows consume -display-only tail space below it. - -## Change intent - -- Reuse the turn reveal seam with a logical top anchor and virtual tail rows. -- Activate only for a real prompt submitted while following the tail in a TTY. -- Cancel on manual navigation; preserve the logical anchor across resize. -- Exclude virtual rows from transcript, search, copy, folds, trim, and pipes. - -## Verification - -- RED/GREEN: Screen anchor lifecycle, streaming, resize, tiny viewport, cancellation. -- PTY: prompt starts at top and reply fills beneath it one row at a time. -- `pnpm run typecheck`. - -## Acceptance - -- [x] No fake row enters transcript-derived behavior. -- [x] Sticky takeover begins without duplicate prompt rows. -- [x] Historical browsing and non-TTY output retain existing behavior. -- [x] Docs, alignment entry, minor changeset, and scoped commit exist. diff --git a/.scratch/grok-surface-roadmap/issues/03-turn-timeline.md b/.scratch/grok-surface-roadmap/issues/03-turn-timeline.md deleted file mode 100644 index 1f30ade9..00000000 --- a/.scratch/grok-surface-roadmap/issues/03-turn-timeline.md +++ /dev/null @@ -1,30 +0,0 @@ -# 03 — Conversation timeline - -Status: completed - -Blocked by: 01, 02 - -## Objective - -Render and operate a one-column conversation timeline without reducing transcript -width. - -## Change intent - -- Add a pure timeline layout module with current-turn windowing and overflow marks. -- Paint dim `·`, user-colored current `●`, and `↑`/`↓` in the reserved right column. -- Hover shows a two-line display-only prompt preview; click jumps to that turn. -- Hide the rail beneath selectors/viewers and omit it from copy/search/pipes. - -## Verification - -- RED/GREEN: pure layout boundaries, Screen painting, hover/click, resize/trim. -- PTY: current marker follows sticky ownership and cross-turn scrolling. -- `pnpm run typecheck`. - -## Acceptance - -- [x] Timeline never changes content wrapping or chrome height. -- [x] Every visible tick maps to the correct real prompt. -- [x] Hover and click do not mutate transcript or selection. -- [x] Docs, alignment entry, minor changeset, and scoped commit exist. diff --git a/.scratch/grok-surface-roadmap/issues/04-copy-content.md b/.scratch/grok-surface-roadmap/issues/04-copy-content.md deleted file mode 100644 index 13663ec8..00000000 --- a/.scratch/grok-surface-roadmap/issues/04-copy-content.md +++ /dev/null @@ -1,30 +0,0 @@ -# 04 — Conversation content index and /copy - -Status: completed - -Blocked by: none - -## Objective - -Copy raw assistant Markdown or an exact fenced code block through a filterable -TTY selector or stable direct address. - -## Change intent - -- Build a pure index over session events with answer `N` and code `N:C` targets. -- Add `/copy`, `/copy N`, and `/copy N:C`; no-argument selection is newest-first. -- Copy raw Markdown for answers and fence-free source for code. -- Never touch the clipboard in non-TTY mode or on cancel/error. - -## Verification - -- RED/GREEN: event indexing, fences, malformed input, commands, clipboard outcomes. -- PTY: selector and OSC 52 receipt. -- `pnpm run typecheck`. - -## Acceptance - -- [x] Tools/images do not create targets; plugin-produced assistant answers remain copyable. -- [x] Copied text contains no ANSI, Rule, sticky copy, or code fence for code targets. -- [x] Missing/disabled clipboard paths report clearly and make no write. -- [x] Docs, alignment entry, minor changeset, and scoped commit exist. diff --git a/.scratch/grok-surface-roadmap/issues/05-fullscreen-viewer.md b/.scratch/grok-surface-roadmap/issues/05-fullscreen-viewer.md deleted file mode 100644 index f726644d..00000000 --- a/.scratch/grok-surface-roadmap/issues/05-fullscreen-viewer.md +++ /dev/null @@ -1,29 +0,0 @@ -# 05 — Fullscreen response viewer - -Status: completed - -Blocked by: none - -## Objective - -Open an answer or code target in a transient, scrollable full-viewport reader. - -## Change intent - -- Add `/view`, `/view N`, and `/view N:C` over the shared content index. -- Render answer Markdown and fence-free code with a title and navigation footer. -- Support wheel, shifted arrows, page/home/end, resize, and Escape restore. -- Do not mutate transcript, search, session events, clipboard, or pipe output. - -## Verification - -- RED/GREEN: viewer layout/navigation/resize/restore and command selection. -- PTY: long answer and code viewers restore the exact prior viewport. -- `pnpm run typecheck`. - -## Acceptance - -- [x] Input chrome and timeline are hidden only while the viewer owns the viewport. -- [x] Wide characters and Markdown reflow safely. -- [x] Non-TTY use reports unsupported without adding UI output. -- [x] Docs, alignment entry, minor changeset, and scoped commit exist. diff --git a/.scratch/grok-surface-roadmap/issues/06-manual-folds.md b/.scratch/grok-surface-roadmap/issues/06-manual-folds.md deleted file mode 100644 index 21b5aba8..00000000 --- a/.scratch/grok-surface-roadmap/issues/06-manual-folds.md +++ /dev/null @@ -1,30 +0,0 @@ -# 06 — Persistent manual fold choices - -Status: completed - -Blocked by: none - -## Objective - -Preserve explicit fold choices through streaming and later turns without changing -the session schema. - -## Change intent - -- Distinguish automatic fold state from explicit expanded/collapsed preferences. -- Click and Ctrl+O pin the chosen state; turn submission collapses automatic folds only. -- New and replayed folds default collapsed; clear/session replacement resets choices. -- Preserve reading position when an expansion occurs near the tail. - -## Verification - -- RED/GREEN: mixed choices, prompt folds, stream completion, resize/trim/search/clear. -- PTY: one manually expanded block stays open across the next turn. -- `pnpm run typecheck`. - -## Acceptance - -- [x] Expanded prompts remain non-sticky until manually collapsed. -- [x] Automatic folds retain current default behavior. -- [x] Resume restores fold capability but not ephemeral manual state. -- [x] Docs, alignment entry, minor changeset, and scoped commit exist. diff --git a/.scratch/research/grok-build-surface-ideas.md b/.scratch/research/grok-build-surface-ideas.md deleted file mode 100644 index 0a11a5a8..00000000 --- a/.scratch/research/grok-build-surface-ideas.md +++ /dev/null @@ -1,48 +0,0 @@ -# Grok Build Surface Ideas for codsh - -Research date: 2026-08-30 -Official source revision: `bc7f02eddd3d84085849dc19ed216f11c23b0571` - -## Recommendation - -Build a conversation timeline and semantic turn navigation next. The Sticky -Turn Header work already gives codsh turn descriptors, physical-row mapping, -resize handling, and trim behavior, so this adds substantial navigation value -without changing the dsh session schema. - -## Surface-only candidates - -1. **Conversation timeline and turn jumps (M)** — one tick per user turn, - current-turn highlight, hover preview, and reversible `/jump` preview. - Sources: [timeline](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/src/views/timeline.rs#L1-L23), - [window and jump calculation](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/src/views/timeline.rs#L65-L170), - [jump view contract](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/src/views/jump.rs#L1-L8). -2. **Copy response/block/metadata and fullscreen block viewer (S-M)** — reuse - codsh clipboard support, but retain raw block source and metadata alongside - rendered rows. Sources: [block actions](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/docs/user-guide/03-keyboard-shortcuts.md#L55-L84), - [`/copy`](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md#L54-L65). -3. **Adaptive scrolling (S-M)** — snap a submitted prompt to the viewport top, - then add wheel/trackpad tuning and a scrollbar. Sources: - [page flip and scroll settings](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/docs/user-guide/05-configuration.md#L177-L201). -4. **OSC 8 links and file:line targets (M)** — visible-link hit maps with a - plain-text fallback when terminal/tmux capabilities are insufficient. - Sources: [link map](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/src/scrollback/link_map.rs#L1-L67), - [routing policy](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/src/hyperlink_route.rs#L9-L38). -5. **Draft stash/restore and external editor (S-M)** — start with text-only - stash, then preserve image chips and add `$VISUAL` / `$EDITOR` editing. - Sources: [stash semantics](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/docs/user-guide/03-keyboard-shortcuts.md#L217-L247), - [`/edit-prompt`](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/docs/user-guide/04-slash-commands.md#L147-L153). -6. **Terminal doctor and richer notifications (M)** — codsh already detects - focus and emits BEL/title updates; add capability findings for Kitty keys, - Shift+Enter, mouse reporting, OSC 52, tmux, and alternate screen before - adding terminal-specific notification routes. Source: - [terminal doctor](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/docs/user-guide/21-terminal-support.md). - -## Requires upstream dsh support - -- `/rewind` and `/fork`: need reliable session truncate/fork APIs. -- `/context`: needs token usage split into system/messages/reasoning/tools/MCP. -- Full active-turn follow-up queue steering: needs runtime injection semantics, - not only a queue pane. - -These should remain later work until the upstream API is verified. diff --git a/.scratch/ship-goal-main-track/issues/01-spec-schema.md b/.scratch/ship-goal-main-track/issues/01-spec-schema.md deleted file mode 100644 index e3a66a99..00000000 --- a/.scratch/ship-goal-main-track/issues/01-spec-schema.md +++ /dev/null @@ -1,24 +0,0 @@ -# Ticket 1: Spec schema (Goal-Id, Main Track, Track: strip) - -Blocked by: none -Track: 8 - -## Delivers - -`parseSpecMetadata` returns `goalId` from a `Goal-Id:` header line. A `parseMainTrack` (or equivalent) reads the `## Main Track` section. `parsePlan` strips `Track: N[,M]` from chrome titles the way it already strips blockers and verification, and does not count `## Main Track` checkboxes as plan tickets. - -## Acceptance - -- [x] `Goal-Id: ` round-trips through `parseSpecMetadata`. -- [x] A spec whose only checkboxes sit under `## Main Track` yields an empty plan. -- [x] A plan line with `Track: 1,3` and `(Blocked by: …)` keeps a short chrome title; blocker stripping still works. -- [x] Existing Branch / Base-Commit / Original-Branch / Status / checkbox cases stay green. -- [x] `pnpm exec vitest run packages/bundle/tests/plan.spec.ts` exits 0. - -## Proof (Track: 8) - -Red-first: `pnpm exec vitest run packages/bundle/tests/plan.spec.ts` exit 1 — 2 failed | 25 passed. -- `reads Goal-Id from spec header metadata (Track: 8)` — expected `undefined` to be `'goal-abc123'` (missing `goalId` on `SpecMetadata`). -- `reads the Main Track section without treating its checkboxes as plan tickets (Track: 8)` — `TypeError: parseMainTrack is not a function`. - -Green: same command exit 0 — 29 tests passed (Goal-Id round-trip, Main Track parse, Track: strip beside blockers, empty plan when only Main Track checkboxes). diff --git a/.scratch/ship-goal-main-track/issues/02-prompt-contracts.md b/.scratch/ship-goal-main-track/issues/02-prompt-contracts.md deleted file mode 100644 index ab2c06e2..00000000 --- a/.scratch/ship-goal-main-track/issues/02-prompt-contracts.md +++ /dev/null @@ -1,28 +0,0 @@ -# Ticket 2: Prompt contracts (prepend, freeze, forbid tools, cites, Ralph) - -Blocked by: none -Track: 2, 3, 4, 5, 7 - -## Delivers - -`shipPromptFor(status, { track?, goalId? })`. Grill template includes `$GOAL_ID`. Every phase forbids `create_goal` / `update_goal` / `get_goal` and silent `## Main Track` rewrite after Confirm; a needed contradiction is a blocker. Spec / tickets / land / done prepend the supplied track. Land requires `Track:` on plan lines and a Track-N cite on the first red test or the ticket commit. Ralph's objective carries the sealed track plus the spec path. Phase 5 no longer authorizes silent decision rewrites; progress (Status, checkboxes, proof logs) remains writable. - -## Acceptance - -- [x] `SHIP_PROMPT` / `shipPromptFor` pin: `## Main Track`, `Track:`, `$GOAL_ID`, forbid goal tools, freeze-after-Confirm, contradiction is a blocker, Track-N cite, Ralph sealed track. -- [x] Grill injection still excludes later phase contracts. -- [x] Spec / tickets / land prepend `track` when provided and still exclude grill. -- [x] `pnpm exec vitest run packages/bundle/tests/ship.spec.ts` exits 0. - -## Proof (Track: 2, 3, 4, 5, 7) - -Red-first (first failing test, then later slices): -- `pnpm exec vitest run packages/bundle/tests/ship.spec.ts` exit 1 — 1 failed | 21 passed. `passes Goal-Id into grill so the spec header records the session compass (Track: 5)` — expected SHIP_PROMPT to contain `$GOAL_ID`. -- `pnpm exec vitest run packages/bundle/tests/ship.spec.ts -t 'prepends the compact Main Track'` exit 1. `prepends the compact Main Track on later phases without substituting the phase contract (Track: 2)` — expected `startsWith(track)` true, got false. -- `pnpm exec vitest run packages/bundle/tests/ship.spec.ts -t 'forbids goal tools'` exit 1. `forbids goal tools in every phase so the model cannot fight the runner (Track: 7)` — expected grill prompt to contain `create_goal`. -- `pnpm exec vitest run packages/bundle/tests/ship.spec.ts -t 'freezes Main Track after Confirm'` exit 1. `freezes Main Track after Confirm so a contradiction is a blocker not a silent rewrite (Track: 3)` — expected later-phase prompt to contain `## Main Track`. -- `pnpm exec vitest run packages/bundle/tests/ship.spec.ts -t 'requires Track: on plan lines|puts the sealed Main Track'` exit 1 — 2 failed. Missing `Track:` cite tokens and `/sealed track/` in the Ralph objective (Track: 4). - -Green: `pnpm exec vitest run packages/bundle/tests/ship.spec.ts` exit 0 — 27 tests passed. -Proof: `pnpm exec vitest run packages/bundle/tests/ship.spec.ts packages/bundle/tests/ship-run.spec.ts packages/bundle/tests/plan.spec.ts` exit 0 — 3 files, 67 tests passed. -typecheck still only red on `packages/bundle/tests/startup.spec.ts` TS2307 `@deepseek-ai/cordis-plugin-include` (baseline; zero-new-failures). diff --git a/.scratch/ship-goal-main-track/issues/03-occupancy-goals-port.md b/.scratch/ship-goal-main-track/issues/03-occupancy-goals-port.md deleted file mode 100644 index c120ba3f..00000000 --- a/.scratch/ship-goal-main-track/issues/03-occupancy-goals-port.md +++ /dev/null @@ -1,31 +0,0 @@ -# Ticket 3: Occupancy + ShipGoals port + degrade + composition wire - -Blocked by: Ticket 1, Ticket 2 -Track: 1, 5, 6, 9 - -## Delivers - -Optional `ShipGoals` and occupancy Selector ports on `ShipRun`. Occupancy runs before the first phase turn: pause a stranger immediately, then `ship · occupancy` (Replace / Abort). TTY cancel/Esc = Abort (resume stranger, inject nothing). Absent ask / non-TTY = auto-Replace. Ours = spec `Goal-Id:` match or objective starts with `[ship]` → edit/pause, no ask. No current / complete: create `[ship] ` then pause; pass id into grill. Missing or throwing port degrades (flash, continue spec+prepend). Length rejection retries once as `[ship] ` then degrades the harness half. Composition root wires `ctx.goals`, `prompt.select`, and flash. No extra chrome row. - -## Acceptance - -- [x] No current goal: create then pause before the first injected prompt; grill prompt contains the Goal-Id. -- [x] Stranger on a TTY: pause, ask; Replace clears then creates; Abort resumes stranger and injects nothing; Esc same as Abort. -- [x] Absent ask: auto-Replace without asking. -- [x] Ours by Goal-Id or `[ship]` prefix: no ask, edit/pause. -- [x] Goals port missing or create/pause throwing: `run()` does not throw; injects still happen. -- [x] Index constructs `ShipRun` with the ports. -- [x] `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts` exits 0. -- [x] `pnpm run typecheck` exits 0. - -## Proof (Track: 1, 5, 6, 9) - -Red-first: -- `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts -t 'creates a \[ship\] placeholder'` exit 1 — 1 failed | 11 skipped. `creates a [ship] placeholder then pauses before the first grill inject (Track: 5,7)` — expected `[]` to equal `['create:[ship] build a widget', 'pause:goal-new']`. -- `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts -t 'pauses a stranger then asks'` exit 1. `pauses a stranger then asks ship · occupancy before any inject (Track: 6)` — expected `undefined` to be `'pause:stranger-1'`. -- `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts` exit 1 — 1 failed | 17 passed. `reuses ours by Goal-Id or [ship] prefix without asking (Track: 6)` — occupancy asked; expected `[]`. -- `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts` exit 1 — 1 failed | 20 passed. `constructs ShipRun with goals, occupancy, and flash ports (Track: 1,9)` — expected source to contain `ctx.get('goals')`. - -Green: `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts` exit 0 — 22 tests passed (create-then-pause, pause-then-ask Replace/Abort/Esc/throw, auto-Replace, ours by Goal-Id and `[ship]`, complete-current, missing/throwing degrade, wrapHostGoals, composition wire, no GoalBar). -Proof: `pnpm exec vitest run packages/bundle/tests/ship.spec.ts packages/bundle/tests/ship-run.spec.ts packages/bundle/tests/plan.spec.ts` exit 0 — 3 files, 78 tests passed. -typecheck still only red on `packages/bundle/tests/startup.spec.ts` TS2307 `@deepseek-ai/cordis-plugin-include` (baseline; zero-new-failures). Length retry of a too-long full-track objective is Ticket 4 (start occupancy only creates `[ship] `). diff --git a/.scratch/ship-goal-main-track/issues/04-snapshot-lifecycle.md b/.scratch/ship-goal-main-track/issues/04-snapshot-lifecycle.md deleted file mode 100644 index fb8de4bc..00000000 --- a/.scratch/ship-goal-main-track/issues/04-snapshot-lifecycle.md +++ /dev/null @@ -1,28 +0,0 @@ -# Ticket 4: Snapshot prepend + lifecycle + re-assert - -Blocked by: Ticket 1, Ticket 2, Ticket 3 -Track: 1, 3, 4, 7 - -## Delivers - -After grill handshake (Status interviewing, draft `## Main Track` on disk): edit objective to `[ship]` + draft track, pause. On Status confirmed: snapshot `## Main Track`; later injects of this run prepend that snapshot even if the file is rewritten; edit objective to `[ship]` + sealed track; pause. Land Ralph objective includes the snapshot and the spec path. Every later inject re-asserts: drifted objective edited back; armed goal paused. Status shipped (session write) completes the ship goal. Abort/Esc after placeholder leaves it paused (complete is not called). Always pause after mutate. - -## Acceptance - -- [x] After a spec write that seals `## Main Track` and Status confirmed, later injects prepend that snapshot even if the file's Main Track is later rewritten. -- [x] Re-assert on a later inject: drifted objective edited back; armed goal paused. -- [x] Status shipped (session write): complete called. -- [x] Abort/Esc after placeholder: complete is not called. -- [x] Land prompt / Ralph objective contains the sealed track and the spec path. -- [x] `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts packages/bundle/tests/ship.spec.ts` exits 0. - -## Proof (Track: 1, 3, 4, 7) - -Red-first: -- `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts -t 'prepends the Confirm snapshot'` exit 1 — 1 failed | 22 skipped. `prepends the Confirm snapshot on later injects even if the file Main Track is rewritten (Track: 3,4)` — expected prompt to contain `**Idea.** Bind /goal into /ship.`, received the phase contract with no snapshot prepend. -- `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts -t 're-asserts a drifted or armed compass'` exit 1. `re-asserts a drifted or armed compass on later injects (Track: 1,7)` — expected log to include `edit:goal-new:[ship] ## Main Track…`. -- `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts -t 'edits the compass to the draft|leaves the placeholder paused on abort|puts the sealed track and spec path'` exit 1 — 2 failed | 1 passed. `edits the compass to the draft then the sealed track and completes on shipped (Track: 7)` — expected draft-track edit; `puts the sealed track and spec path into the land Ralph objective (Track: 4)` — expected land prompt to contain the spec path. - -Green: `pnpm exec vitest run packages/bundle/tests/ship-run.spec.ts` exit 0 — 27 tests passed (Confirm snapshot prepend, re-assert edit-back + pause, draft then sealed then complete, abort leaves paused, Ralph land prompt carries sealed track + spec path). -Proof: `pnpm exec vitest run packages/bundle/tests/ship.spec.ts packages/bundle/tests/ship-run.spec.ts packages/bundle/tests/plan.spec.ts` exit 0 — 3 files, 83 tests passed. -typecheck still only red on `packages/bundle/tests/startup.spec.ts` TS2307 `@deepseek-ai/cordis-plugin-include` (baseline; zero-new-failures). diff --git a/.scratch/ship-goal-main-track/issues/05-release-docs.md b/.scratch/ship-goal-main-track/issues/05-release-docs.md deleted file mode 100644 index 41e4deb1..00000000 --- a/.scratch/ship-goal-main-track/issues/05-release-docs.md +++ /dev/null @@ -1,28 +0,0 @@ -# Ticket 5: Release & Documentation Compliance - -Blocked by: Ticket 1, Ticket 2, Ticket 3, Ticket 4 -Track: 9 - -## Delivers - -CONTEXT.md Ship gates names Main Track, occupancy, and hybrid compass. Bilingual README `/ship` sections name occupancy, the frozen track, and that `/goal` shows the compass during a run. A changeset on `codsh-bundle` with a `minor` bump. - -## Acceptance - -- [x] `rg -n "Main Track|occupancy" CONTEXT.md README.md README.zh.md` prints matches in all three. -- [x] `.changeset/` includes a file naming `'codsh-bundle': minor` for this workflow. -- [x] `pnpm run typecheck` exits 0. -- [x] `pnpm test` exits 0 with zero new failures against the Phase 3 baseline. - -## Proof (Track: 9) - -Red-first (docs seam; no vitest for this ticket): -- `python3` search of `Main Track|occupancy` in CONTEXT.md / README.md / README.zh.md — 0 hits in all three (Track: 9). -- `.changeset/` had no file for this workflow (`'codsh-bundle': minor` + Main Track / occupancy). - -Green: -- Same search: CONTEXT.md 6 hits (Main Track, occupancy, hybrid compass under Ship gates); README.md 1 hit; README.zh.md 1 hit. -- `.changeset/ship-main-track-compass.md` names `'codsh-bundle': minor`. -- `pnpm exec vitest run packages/bundle/tests/ship.spec.ts packages/bundle/tests/ship-run.spec.ts packages/bundle/tests/plan.spec.ts` exit 0 — 3 files, 83 tests passed. -- `pnpm run typecheck` still only red on `packages/bundle/tests/startup.spec.ts` TS2307 `@deepseek-ai/cordis-plugin-include` (baseline; zero-new-failures). -- `pnpm test` still only red on that same suite (49 files passed, 1361 tests passed); no new failures. diff --git a/.scratch/ship-goal-main-track/spec.md b/.scratch/ship-goal-main-track/spec.md deleted file mode 100644 index 0a73faeb..00000000 --- a/.scratch/ship-goal-main-track/spec.md +++ /dev/null @@ -1,212 +0,0 @@ -# Bind /goal into /ship as a Main Track compass - -Status: shipped -Branch: ship/ship-goal-main-track -Base-Commit: 48b4b4b8dd06dff9e9ab98007add1e4c93ef12a0 -Original-Branch: main - -## Requirement - -goal 指令没有什么作用,考虑跟 ship 指令做一个结合,ship 的整个过程中,agent 容易偏离原来的主设计轨道;goal 就是起这个作用,这里 ship 要怎么设计才能保证后续所有阶段的每一笔都是种遵循主轨道。 - -## Problem Statement - -A person types `/ship` with one sentence and a long grill. The confirmed spec is supposed to be the design. In later phases the agent only sees the current phase contract plus an instruction to re-read that file. Under TDD pressure, a Ralph loop, or a mid-flight surprise, it quietly rewrites the spec to match what it already built. `/goal` exists as a same-session compass, but it does nothing useful during `/ship`: ship turns are plugin-sourced so the model cannot create a goal, the harness round driver would fight phase injection if a goal were armed, and fresh Ralph workers never inherit the parent session goal. The person who wanted `/goal` to keep the work on the main track instead watches `/ship` drift. - -## Solution - -`/ship` keeps the spec as durable memory and the phase loop as scheduler. Grill produces a compact **Main Track** (the idea, numbered Track-N decisions, Out of Scope). Gate 1 Confirm seals it. From then on every later `/ship` turn is prepended with a process snapshot of that track — not a live reread the agent can rewrite this run. Plan tickets name the Track-N ids they implement; landing must cite them on the first red test or the ticket commit. A large plan puts the sealed track into the Ralph objective as well as the spec path. - -The harness session goal is a **disarmed compass**, not a second scheduler. The ship runner owns occupancy and lifecycle through a narrow goals port: pause an unrelated current goal before asking, replace or abort, create a `[ship]` placeholder, edit it to the draft then the sealed track, complete on shipped, leave it paused on abort. If the goals port is missing or throws, the spec+prepend path still binds later phases. `/goal` remains the human command; during a ship run it merely shows the compass. - -## Grill Decisions - -1. **Hybrid compass** — Spec stays the durable design contract. Harness `/goal` is a *disarmed* session compass whose objective is the compact Main Track. The ship runner stays the scheduler and never arms continuation, so the harness round driver cannot inject generic goal-round prompts that fight phase contracts. Reason: `/ship` already owns phase injection; the harness goal is one-current-goal state, not a scheduler we can reuse. Ralph workers do not inherit the parent session goal. - -2. **Compact Main Track payload** — The track is the one-sentence idea, numbered Track-N grill decisions, and Out of Scope — not the full spec. Full stories, seams, and tickets stay in the spec file. Reason: dumping the whole spec into every later turn would undo phased injection (grill / to-spec / tickets / TDD do not share context on purpose). - -3. **Freeze at gate 1 Confirm** — Grill writes a *draft* `## Main Track`. to-spec may complete Out of Scope and tidy wording. Gate 1 Confirm *seals* it. Later phases may update Status, checkboxes, and proof logs, but must not rewrite frozen decisions. A needed contradiction is a blocker (ask / circuit breaker), never a silent spec edit. Reason: today's Phase 5 "update the spec when a decision changes" is exactly how the agent leaves the main track. - -4. **Mechanical prepend + per-ticket cite** — Later phase injections prepend the track. After Confirm, prepend a *process snapshot* of `## Main Track` captured at seal time, not a live disk reread the agent can rewrite this run. Each plan ticket records `Track: N[,M]`. The landing prompt requires the first red test (or the ticket commit message) to cite those ids. Ralph's objective carries the sealed track verbatim plus the spec path. Reason: "re-read the spec" is the current miss; instruction-only citation is honor system. - -5. **Ship owns the session goal** — A narrow goals port on the ship runner wraps the host goal service when present. Missing or throwing service *degrades*: spec+prepend still binds later phases; flash that `/goal` was not updated. Do not abort the ship run. Objective text is `[ship]` plus the full compact track; if create rejects on length, fall back to `[ship] ` and keep the full track in prompt prepend. Spec header `Goal-Id:` is passed into the grill prompt and written by the model. Reason: model goal tools require a direct human user-turn; `/ship` turns are plugin-sourced. The bundle does not add a goal-package dependency. - -6. **Occupancy, before inject** — Harness allows one current goal. If an *unrelated* current goal exists at `/ship` start: pause it immediately (stops the round driver), then a Selector with header `ship · occupancy` (Replace / Abort). TTY Esc/cancel = Abort: resume the paused stranger and stop `/ship`. Non-TTY: auto-Replace. Replace → clear → create placeholder → pause, all before the first phase turn. Ours: current id matches spec `Goal-Id:`, or the objective starts with `[ship]` → edit/pause, do not ask. Resume of an unfinished spec uses the same occupancy+placeholder/edit path. Reason: create cannot start a new goal while one is unfinished; pause does not free the slot. Asking while the stranger is still armed lets a goal-round fire in the idle modal. - -7. **Lifecycle writes** — Start: create placeholder `[ship] ` then pause (create always arms; pause synchronously before awaiting the first turn). After grill handshake: edit to the draft track, pause. Gate 1 Confirm: edit to the sealed track, pause, snapshot. Status shipped: complete. Abort / Esc / gate Abort: leave the ship goal paused. Always pause after mutate. On every later phase inject, re-assert: if the human `/goal` edited the objective, edit it back to the spec track; if activation is armed, pause. Model must not call goal tools during ship; prompt token in every phase. Reason: human `/goal resume` would otherwise rearm the driver mid-run. - -8. **Schema** — New `## Main Track` section (idea, numbered Track-N lines, Out of Scope bullets). Plan lines gain `Track: N[,M]` beside blockers. Plan parsing keeps stripping metadata so chrome titles stay short. Spec metadata gains `Goal-Id:`. Transparent Markdown only — no JSON sidecar. - -9. **Chrome** — No new chrome row. MetaBar chip and plan row stay as they are. `/goal` (if the person runs it) is a side channel showing the `[ship]` objective. No TTY GoalBar. - -10. **Out of scope (product)** — No harness fork. `/goal` stays the human-owned command. No machine evaluator of track-compliance beyond prompt tokens and unit tests. Do not make `/goal` a canned prompt like `/ship`. Occupancy is a Selector, not a ship gate modal. Preflight (dirty tree) stays inside Phase 0 of the grill prompt; occupancy is the only new runtime modal, because it is the driver race. - -## Main Track - -Sealed at gate 1 Confirm. Later phases must not rewrite these lines. Progress (Status, checkboxes, proof logs) remains writable. - -**Idea.** Bind `/goal` into `/ship` so every later phase stays on the grilled design. - -**Track-1.** Hybrid compass: spec is durable memory; harness goal is a disarmed session compass; the ship runner stays the scheduler and never arms continuation. - -**Track-2.** Compact payload: idea + numbered Track-N decisions + Out of Scope. Not the full spec. - -**Track-3.** Freeze at gate 1 Confirm. Contradiction is a blocker, never a silent spec rewrite. - -**Track-4.** Mechanical prepend of a process snapshot after Confirm; each plan ticket names `Track: N[,M]`; first red test or commit cites those ids; Ralph objective carries the sealed track plus the spec path. - -**Track-5.** Ship owns the session goal through a narrow port; missing/throwing service degrades to spec+prepend; objective is `[ship]` plus the track (length fallback `[ship] `); `Goal-Id:` is injected into grill and written on the spec. - -**Track-6.** Occupancy before inject: pause a stranger first, then Selector `ship · occupancy` (Replace / Abort). TTY cancel = Abort (resume stranger, stop ship). Non-TTY auto-Replace. Ours = matching `Goal-Id:` or `[ship]` prefix. - -**Track-7.** Lifecycle: placeholder at start, draft after grill, sealed at Confirm, complete on shipped, paused on abort; always pause after mutate; re-assert on every later inject; model must not call goal tools. - -**Track-8.** Schema: `## Main Track`, `Track:` on plan lines, `Goal-Id:` metadata, Markdown only. - -**Track-9.** No new chrome row; no TTY GoalBar. - -**Out of Scope.** -- Forking or patching the harness goal service, command, tools, or round driver. -- A TTY GoalBar or extra chrome row for the track. -- Turning `/goal` into a canned `/ship`-style prompt. -- Arming harness continuation during a ship run. -- A machine evaluator that parses tests or git history for Track-N cites (prompt tokens and unit tests only). -- Moving dirty-tree preflight out of the grill prompt into the runner. -- Restoring a replaced stranger goal after ship completes (one-current-goal limit). -- Creating a harness goal inside Ralph workers (they cannot; the sealed track rides their objective). - -## User Stories - -1. As a developer running `/ship`, I want every later phase to be shown the grilled design as a compact compass, so that the agent cannot rely on a fading conversation or a polite "re-read the spec". -2. As a developer who confirmed the spec, I want that compass frozen, so that landing cannot rewrite the design to match whatever it already built. -3. As a developer whose landing work needs to contradict a frozen decision, I want the run to stop and ask me, so that a real design change is a conscious choice. -4. As a developer who already has a `/goal` when I type `/ship`, I want to choose Replace or Abort before any ship turn starts, so that `/ship` does not silently steal my current objective. -5. As a developer who cancels that occupancy choice, I want my previous `/goal` restored and `/ship` stopped, so that closing the dialog does not delete my work. -6. As a developer running `/ship` on a pipe, I want a leftover `/goal` replaced automatically, so that a scripted run is not frozen on an occupancy question. -7. As a developer whose session has no working goal service, I want `/ship` to continue with the spec compass alone, so that a missing host plugin does not block delivery. -8. As a developer who runs `/goal` during a ship run, I want it to show the ship compass with a clear `[ship]` mark, so that the command is no longer empty during the work that needs it. -9. As a developer who resumes `/goal` by accident mid-ship, I want the next ship turn to put the compass back and keep auto-continue off, so that a generic continuation round cannot fight the current phase. -10. As a developer who Aborts a gate or Escapes the run, I want the ship compass left paused, so that a later `/ship` resume still has it. -11. As a developer resuming an unfinished spec, I want occupancy and the compass restored the same way as a new run, so that a restarted session is not spec-only with an empty `/goal`. -12. As a developer whose current `/goal` is already the ship compass, I want `/ship` to reuse it without asking, so that resume does not prompt me to delete my own track. -13. As a developer landing a ticket, I want that ticket to name which track decisions it implements, so that a reviewer can see the work is on-rail. -14. As a developer whose plan is large enough for fresh workers, I want those workers to receive the sealed compass in their mission, so that a new agent without the parent session still follows the track. -15. As a reviewer of the spec file, I want a dedicated Main Track section and a Goal-Id in the header, so that the compass is readable without the conversation. -16. As a developer looking at the terminal chrome, I want the existing ship chip and plan row unchanged, so that the compass does not add another pinned line. -17. As a developer using `/goal` by itself, I want it to remain the ordinary human command, so that `/ship` does not replace or hide it. -18. As a developer whose ship compass is too long for the session slot, I want a short fallback in `/goal` while the full track still rides every later `/ship` turn, so that a length rejection does not drop the compass. -19. As a developer during grill, I want the spec header to record the session goal id the run created, so that later occupancy can recognize our compass. -20. As a person answering occupancy, I want only Replace and Abort, so that occupancy cannot become a free-text third policy. -21. As a developer on a later `/ship` turn, I want the phase prompt to forbid goal tools, so that the model does not fight the runner for the session slot. -22. As a maintainer of the workflow docs, I want the Main Track, occupancy, and freeze rules named in the product language, so that `/ship` and `/goal` are not described as unrelated. - -## Implementation Decisions - -1. **One runner still owns ship memory.** - The existing ship-run module remains the only session owner of spec polling, the MetaBar chip, the plan row, and phase injection. Occupancy, the goals port, the sealed-track snapshot, prompt prepend, and lifecycle writes join that module. The composition root only begins a run, notes a markdown write, aborts, and wires two ports: chrome (as today) plus optional goals and an optional occupancy ask. - -2. **Narrow `ShipGoals` port — no new package dependency.** - The runner never imports the host goal service type. The port exposes get / create / edit / pause / resume / complete / clear plus id, objective, phase, and activation. The composition root wraps `ctx.goals` when present; tests inject a fake. Missing port or a thrown error degrades: flash that `/goal` was not updated, continue spec+prepend. Length rejection on create retries once as `[ship] ` then degrades the harness half. - -3. **Occupancy ask port.** - A Selector, not a gate modal (gates remain spec and tickets). Title/header exactly `ship · occupancy`. Options: Replace (first), Abort. TTY: `cancelled` / empty / Esc = Abort — resume the paused stranger, stop the run without injecting a phase. Non-TTY or absent ask: auto-Replace. Occupancy runs after the runner is constructed and before the first phase turn is awaited. - -4. **Ours vs stranger.** - A current goal is ours when its id equals the live spec's `Goal-Id:`, or when its objective starts with `[ship]`. Ours: edit/pause, never ask. Stranger (non-complete): pause immediately, then ask. Complete or absent: create placeholder. Stale `Goal-Id:` across sessions is ignored and rewritten after a successful create. - -5. **Prompt contracts.** - `shipPromptFor` gains an optional sealed-track argument (and grill gains `$GOAL_ID` via the same template expander already used for `$ARGUMENTS`). Grill / spec / tickets / land / done each contain: forbid goal tools; do not rewrite `## Main Track` after Confirm; contradiction is a blocker. Spec / tickets / land / done prepend the snapshot when one exists, otherwise the live `## Main Track` from disk while still a draft. Land requires `Track: N[,M]` on each plan line and a Track-N cite on the first red test or the ticket commit. Ralph's objective is the sealed track verbatim plus the spec path. Phase 5 no longer authorizes silent decision rewrites; progress (Status, checkboxes, proof logs) remains writable. - -6. **Spec schema.** - Header may include `Goal-Id: `. A `## Main Track` section holds the idea, Track-N lines, and Out of Scope bullets. Plan checkboxes may include `Track: 1,3` next to blockers. Plan parsing strips `Track:` the way it already strips blockers and verification so chrome titles stay short. Metadata parsing returns `goalId` when present. - -7. **Lifecycle relative to Status.** - - Run start, no ours: occupancy as above, then create `[ship] `, pause, pass the new id into the grill injection. - - Grill handshake (Status becomes interviewing, spec now has a draft track): edit objective to `[ship]` + draft track, pause. - - Gate 1 Confirm (Status becomes confirmed): capture `## Main Track` into the run's snapshot; edit objective to `[ship]` + sealed track; pause. Later injects of this run prepend the snapshot. - - Status shipped, dual-layer DoD passed: complete the current ship goal. - - Abort / Esc / occupancy Abort: leave the ship goal paused; on occupancy Abort, resume the stranger instead. - - Every later phase inject: if objective drifted, edit back; if armed, pause. - -8. **Create-then-pause race.** - Create always arms. Pause runs synchronously in the same turn as create, before the first phase followup is awaited. The agent is not idle, so the round driver should not admit a round. Do not fork the harness to add create-paused. - -9. **Docs and release.** - Domain language (Main Track, occupancy, hybrid compass) updates CONTEXT.md under Ship gates. Bilingual README `/ship` sections name occupancy, the frozen track, and that `/goal` shows the compass during a run. A changeset on `codsh-bundle` (minor: user-visible workflow). - -## Testing Decisions - -Prior art for `/ship` is already three public seams that this change extends, not a new mock-heavy interior. Prefer those same seams; do not add a fourth package or a harness-mounted GoalService in unit tests. - -1. **Primary seam — `ShipRun` public API** (`ship-run` tests). - Construct with chrome plus optional `ShipGoals` and occupancy ask fakes. Prove, without a live host: - - No current goal: create placeholder then pause before the first injected prompt; grill prompt contains the returned Goal-Id. - - Stranger on a TTY: pause, ask `ship · occupancy`; Replace clears then creates; Abort resumes the stranger and injects nothing. - - Occupancy cancel/Esc: same as Abort. - - Absent ask (pipe): auto-Replace without asking. - - Ours by Goal-Id or `[ship]` prefix: no ask, edit/pause. - - Goals port missing or create/pause throwing: run continues; injected prompts still carry the spec track; no throw out of `run()`. - - After a spec write that seals `## Main Track` and Status confirmed: later injects prepend that snapshot even if the file's Main Track is later rewritten. - - Re-assert on a later inject: drifted objective edited back; armed goal paused. - - Status shipped (session write): complete called. - - Abort/Esc after placeholder: complete is not called. - - Ralph path (land prompt): objective text for the loop contains the sealed track and the spec path. - -2. **Prompt-token seam — `shipPromptFor` / `SHIP_PROMPT`** (`ship` tests). - Pin tokens that must not regress: `## Main Track`, `Track:`, `$GOAL_ID`, `ship · occupancy`, forbid `create_goal` / `update_goal` / `get_goal`, freeze-after-Confirm, contradiction is a blocker, Track-N cite on the first red test or commit, Ralph objective carries the sealed track. Grill injection still excludes later phase contracts; spec/tickets/land still exclude grill. Prepend appears on spec/tickets/land/done, not as a substitute for the phase contract. - -3. **Schema seam — plan parsing** (`plan` tests). - `Goal-Id:` round-trips in metadata. `## Main Track` is not counted as plan tickets. A plan line `Track: 1,3` is stripped from the chrome title and does not break blocker stripping. Existing Branch / Base-Commit / Original-Branch / Status / checkbox cases stay green. - -Do not test host GoalService, the Web GoalBar, or `dsh-command-goal`. Do not parse real test files or git logs for Track-N cites in production code. - -## Out of Scope - -- Forking or patching `@deepseek-ai/dsh-goal`, `dsh-tool-goal`, `dsh-command-goal`, or `dsh-goal-round-driver`. -- Adding `@deepseek-ai/dsh-goal` as a bundle dependency or peer solely to type the host service. -- A TTY GoalBar, extra chrome row, or MetaBar chip kind for the track. -- Making `/goal` a canned prompt, or routing `/goal` through `ShipRun`. -- Arming harness continuation at any point in a ship run. -- A machine evaluator, git hook, or commit-message linter that rejects missing Track-N cites. -- Restoring a replaced stranger goal after ship completes. -- Creating or mutating goals inside Ralph / workflow child sessions. -- Moving dirty-tree preflight (`ship · preflight`) from the grill prompt into the runner. -- Changing rewind, turn-source, or plugin-sourced canned-command provenance. -- Remote Git hosting, issue-tracker publication (tracker unconfigured), or new GateModal kinds. - -## Acceptance Criteria - -1. `pnpm exec vitest run packages/bundle/tests/ship.spec.ts packages/bundle/tests/ship-run.spec.ts packages/bundle/tests/plan.spec.ts` exits 0, and the new cases named in Testing Decisions are present and green (occupancy pause-then-ask, snapshot prepend after Confirm, goals-port degrade, `Goal-Id` / `Track:` parsing, forbid-goal-tools tokens, Ralph track in the land prompt). -2. `pnpm run typecheck` exits 0. -3. `pnpm test` exits 0 with zero new failures against the Phase 3 baseline. -4. `rg -n "Main Track|ship · occupancy|\\$GOAL_ID" packages/bundle/src/ship.ts packages/bundle/src/ship-run.ts packages/bundle/src/plan.ts` prints matches in all three modules (the contracts live in code, not only in this spec). -5. `rg -n "Main Track|occupancy" CONTEXT.md README.md README.zh.md` prints matches in all three docs. -6. `ls .changeset/*.md` includes a changeset that names `codsh-bundle` with a `minor` bump for this workflow change. - -The issue tracker is unconfigured for agent work. Local store: `.scratch/ship-goal-main-track/spec.md` and `.scratch/ship-goal-main-track/issues/`. Run `/setup-engineering-workflows` if later tickets should land in a tracker; this pass will not invent one. - -## Plan - -- [x] Ticket 1: Spec schema — Delivers Goal-Id metadata, ## Main Track parse, and Track: strip on plan lines so chrome titles stay short (Blocked by: none) (Track: 8) -- [x] Ticket 2: Prompt contracts — Delivers shipPromptFor track/goalId, $GOAL_ID, forbid goal tools, freeze-after-Confirm, Track-N cites, Ralph sealed track (Blocked by: none) (Track: 2,3,4,5,7) -- [x] Ticket 3: Occupancy and ShipGoals port — Delivers pause-then-ask ship · occupancy, ours-vs-stranger, create-then-pause, goals-port degrade, composition wire; no extra chrome (Blocked by: Ticket 1, Ticket 2) (Track: 1,5,6,9) -- [x] Ticket 4: Snapshot lifecycle — Delivers Confirm snapshot prepend, handshake/seal/complete/abort lifecycle, re-assert on later injects, Ralph objective includes sealed track (Blocked by: Ticket 1, Ticket 2, Ticket 3) (Track: 1,3,4,7) -- [x] Ticket 5: Release & Documentation Compliance — Delivers Changeset and bilingual documentation updates (README.md and README.zh.md) if user-facing code or bundle/cli is touched (Blocked by: Ticket 1, Ticket 2, Ticket 3, Ticket 4) (Track: 9) - -## Baseline - -- Branch: `ship/ship-goal-main-track` at `48b4b4b` plus this spec (untracked) and `.scratch/ship-goal-main-track/` (untracked). Unrelated untracked: `.dsh/`, `.pnpm-store/`. No other tracked diffs. -- Proof command 1 (`pnpm exec vitest run packages/bundle/tests/ship.spec.ts packages/bundle/tests/ship-run.spec.ts packages/bundle/tests/plan.spec.ts`): exit 0 — 3 files, 57 tests passed. -- Proof command 2 (`pnpm run typecheck`): **already red** — `packages/bundle/tests/startup.spec.ts(13,21): error TS2307: Cannot find module '@deepseek-ai/cordis-plugin-include'`. Pre-existing; zero-new-failures applies. -- Proof command 3 (`pnpm test`): **already red** — 49 files passed (1335 tests), 1 failed suite `packages/bundle/tests/startup.spec.ts` (`Cannot find package '@deepseek-ai/cordis-plugin-include'`). Same pre-existing hole; green for this ship means no new failures beyond that suite. - -## Verification - -Parent session re-ran dual-layer DoD after Ralph (loop report is not verification). - -1. `pnpm exec vitest run packages/bundle/tests/ship.spec.ts packages/bundle/tests/ship-run.spec.ts packages/bundle/tests/plan.spec.ts` — exit 0, 3 files, 83 tests (was 57 at baseline). Named cases present: occupancy pause-then-ask / Abort / Esc / pipe auto-Replace / ours; snapshot prepend after Confirm; goals-port degrade; Goal-Id / Track: / Main Track parse; forbid-goal-tools; Ralph track in land prompt. -2. `pnpm run typecheck` — **cannot meet literal exit 0**. Same pre-existing TS2307 on `packages/bundle/tests/startup.spec.ts` (`@deepseek-ai/cordis-plugin-include`) recorded in Baseline. No new type errors. Layer 2 zero-new-failures holds. -3. `pnpm test` — 49 files / 1361 tests passed (was 1335); the only failed suite is still `startup.spec.ts` with the same missing package. Layer 2 zero-new-failures holds. -4. `Main Track` / `ship · occupancy` / `$GOAL_ID` match in `packages/bundle/src/plan.ts`, `ship-run.ts`, and `ship.ts`. -5. `Main Track` / occupancy match in `CONTEXT.md`, `README.md`, `README.zh.md`. -6. `.changeset/ship-main-track-compass.md` names `'codsh-bundle': minor`. diff --git a/CONTEXT.md b/CONTEXT.md index d3b8f7bd..bd169c87 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -37,7 +37,9 @@ The real user prompt that owns the response currently crossing the top of the Viewport. Its display-only copy stays pinned until the next real prompt pushes it away, shrinking from at most three rows to one. Pinned, it is a panel: one padding row of its fill above the prompt and one below, then the divider that -hands the screen back to the transcript. A prompt longer than three visual rows is a Fold by +hands the screen back to the transcript. The fill is the person's band in +the active Theme; a Theme that paints no bands (`terminal`), and `NO_COLOR`, +leave the panel unfilled. A prompt longer than three visual rows is a Fold by default. Clicking the pinned copy expands that floating panel in place; the inline prompt and the reading position stay where they were. Ctrl+O on the original still expands the transcript fold, which then does not pin. @@ -269,7 +271,7 @@ runs — ticking the same Braille frames as the working line, which names the thought `thinking` — and becomes its clock (`│ thought for 3.2s`) when it ends. Both heads occupy one row without vertical padding and without a panel fill — a full-width thinking background on the clock is a black bar -between tool rows. The magenta `│` runs down the clock, the pads, and the +between tool rows. The light-blue `│` runs down the clock, the pads, and the expanded deliberation. Only the expanded form keeps the fill and its panel inset, behind a click or Ctrl+O. Every tool card is a Fold that opens folded: one row naming the call, how much it produced (`+n -m`, `N results`, @@ -364,12 +366,37 @@ _Avoid_: margin, padding, spacer **Rule**: The connecting `│` drawn down every transcript row, including blank separators that belong to a block. Colour — not a different character — -says what the row is: cyan for the person's own message, magenta for -thinking, dim for a tool card or runner notice, red for a failed one, +says what the row is: DeepSeek blue (the Theme's accent) for the person's +own message, its lighter tint for thinking, dim for a tool card or runner +notice, red for a failed one, muted for assistant prose and system chrome. Chrome, not content: it repeats on wrapped rows and never reaches the clipboard. _Avoid_: border, gutter, sidebar +**Theme**: +The palette every role, fill, and the terminal cursor are painted in, chosen +with `/theme` and modelled on Grok CLI's slot themes: `deepseek` (dark), +`deepseek-light`, `terminal` (the terminal's own sixteen colours, no bands — +the person's message is bold instead), and `auto`, the default, which follows +the OSC 11 background between the two DeepSeek themes. The brand is DeepSeek +blue #4D6BFE (accent: frame, the person's rule, markers, the mark, the +cursor via OSC 12) with its tint #7E96F5 (thinking, links, headings, paths); +every slot is RGB with explicit 256- and 16-colour overrides. The choice is +kept under `theme` in `$DSH_HOME/code-cli-ui.json`; `CODSH_THEME` overrides it +for one launch. Switching mutates the one theme record in place, so every +renderer holding a role paints the new palette from its next call. +_Avoid_: skin, colour scheme + +**Repaint**: +Replaying the live session into a fresh transcript so rows already painted +take a new Theme — the chrome restyles at once, the history once nothing is +in flight: a running turn or an open Child view defers it to the turn's end or +the return to the parent. It rebuilds rendering only; approvals, the roster, +the queue, and clocks are untouched. Like a resume, it cannot show what never +reached the session log (command reports, `!` output, notices), and expanded +folds come back folded. +_Avoid_: refresh, redraw + **Scrollback notice**: The row that says how far back the reader has gone and takes the click that ends it. Display-only, drawn over the Viewport's last row — under what is @@ -886,8 +913,10 @@ _Avoid_: armed continuation, canned /goal, second scheduler ### Alignment pipeline **Reference Agent**: -One of the four agent CLIs codsh aligns against: Claude Code, opencode, -Codex CLI, gemini-cli. Claude Code wins ties (ADR-0001). +One of the agent CLIs codsh aligns against. For the interactive TUI, Grok CLI +is primary and wins ties; Claude Code decides where Grok has no equivalent, +then opencode, with Codex CLI and gemini-cli as corroboration (ADR-0002). +Outside the TUI, Claude Code still wins ties (ADR-0001). **Alignment Matrix**: The in-repo table (`docs/alignment.md`) of every interaction/feature gap and diff --git a/README.md b/README.md index 8a0928cb..a6b6eb69 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ Common flags: Designed for high-efficiency, keyboard-driven terminal development: - **Clean Terminal UI**: Full-screen alternate buffer; input stays pinned at the bottom; restores your shell cleanly on exit. +- **DeepSeek Blue Themes**: `/theme` opens a picker that previews each theme live (arrows preview, `Enter` saves, `Esc` reverts); `/theme ` switches directly. `auto` (default) follows the terminal's light or dark background with `deepseek` / `deepseek-light`; `terminal` paints only your terminal's own sixteen colours, with no backgrounds. The choice is saved in `$DSH_HOME/code-cli-ui.json`; `CODSH_THEME=` overrides it for one launch. - **Foldable Reasoning**: Streaming thoughts collapse into a single line (`Ctrl+O` or click to expand/collapse). - **Subagent Matrix**: Background and parallel subagents run in isolated views (`Ctrl+H` to list, click/enter to inspect, `Esc` to return). - **Timeline Navigation**: Jump between dialogue turns (`Shift+←/→`, `/jump`) or branch off from an earlier turn (`/rewind`). @@ -129,7 +130,7 @@ dsh --profile code | Second-class | Ghostty, kitty, Alacritty, Warp | Fully supported; regressions handled as bugs | | Best-effort | Native Windows (pwsh) | Basic TTY support; persistent PTY unavailable | -Supports Kitty keyboard protocol, focus reporting, OSC 11 color detection, and terminal image rendering where available. +Supports Kitty keyboard protocol, focus reporting, OSC 11 color detection (picks the light or dark theme), OSC 12 cursor color (the theme's accent, restored on exit), and terminal image rendering where available. ## Development diff --git a/README.zh.md b/README.zh.md index fb8f00bc..5772ce94 100644 --- a/README.zh.md +++ b/README.zh.md @@ -72,6 +72,7 @@ codsh 专为纯键盘高效开发打造的终端交互界面: - **自成空间的终端界面**:备用屏幕全屏呈现,输入框钉底,退出后完整还回原始 shell。 +- **DeepSeek 蓝主题**:`/theme` 打开主题选择器,方向键实时预览、`Enter` 保存、`Esc` 还原;`/theme <名称>` 直接切换。`auto`(默认)随终端明暗背景在 `deepseek` / `deepseek-light` 之间自动选择;`terminal` 只使用终端自身的 16 色、不绘制背景。选择保存在 `$DSH_HOME/code-cli-ui.json`,`CODSH_THEME=<名称>` 可在单次启动时覆盖。 - **折叠式思考流**:思考过程默认只占单行,实时流式更新(按 `Ctrl+O` 或点击展开/折叠)。 - **子代理视图矩阵**:后台与并行子代理以独立视图运行(按 `Ctrl+H` 查看列表,点击进入,`Esc` 退出)。 - **时间线任意穿梭**:通过 `Shift+←/→` 或 `/jump` 快速跳转轮次;支持用 `/rewind` 从任意轮次分叉探索。 @@ -129,7 +130,7 @@ dsh --profile code | 二等 | Ghostty、kitty、Alacritty、Warp | 现代终端特性良好支持,出问题作 bug 处理 | | 尽力支持 | 原生 Windows(pwsh) | 基础交互可用,受限环境暂不支持持久 PTY | -在支持的终端上自动启用 Kitty 键盘协议、焦点感知、OSC 11 颜色查询与内联图像。 +在支持的终端上自动启用 Kitty 键盘协议、焦点感知、OSC 11 颜色查询(据此选择明暗主题)、OSC 12 光标着色(主题强调色,退出时还原)与内联图像。 ## 开发 diff --git a/assets/banner.svg b/assets/banner.svg index 53a53dbb..f70d6c12 100644 --- a/assets/banner.svg +++ b/assets/banner.svg @@ -1,6 +1,6 @@ codsh — a terminal coding agent, composed on the DeepSeek Harness -A dark terminal panel. On the left the codsh mark — a whale carrying a sail whose leading edge is a slash, /ship riding the harness — beside the wordmark with a blinking block cursor and a one-line description. On the right a miniature transcript in the surface's own marks: the person's own message as a filled plum panel behind a cyan rule, a tool call as a card on its own panel behind an amber rule with a green tick and a diff count, a collapsed thinking row behind a magenta asterisk, the timeline rail marking the turn down the right edge, the input box that never leaves the bottom, the working row, and the status row. +A dark terminal panel. On the left the codsh mark — a whale carrying a sail whose leading edge is a slash, /ship riding the harness — beside the wordmark with a blinking block cursor and a one-line description. On the right a miniature transcript in the surface's own marks: the person's own message as a filled navy panel behind a DeepSeek-blue rule, a tool call as a card on its own panel behind an amber rule with a green tick and a diff count, a collapsed thinking row behind a light-blue asterisk, the timeline rail marking the turn down the right edge, the input box that never leaves the bottom, the working row, and the status row. \ No newline at end of file diff --git a/docs/adr/0001-reference-agents-and-arbitration.md b/docs/adr/0001-reference-agents-and-arbitration.md index cb078d57..a1863541 100644 --- a/docs/adr/0001-reference-agents-and-arbitration.md +++ b/docs/adr/0001-reference-agents-and-arbitration.md @@ -1,5 +1,9 @@ # Reference agents, and Claude Code wins ties +> Superseded for the interactive TUI by [ADR-0002](0002-grok-cli-primary-tui-reference.md): +> there Grok CLI wins ties and Claude Code decides only where Grok has no +> equivalent. This ADR still arbitrates everything outside the TUI. + codsh aligns its interaction design against four reference agents — Claude Code, opencode, Codex CLI, and gemini-cli — and when they disagree on a behavior, Claude Code's behavior is copied; where Claude Code lacks the diff --git a/docs/adr/0002-grok-cli-primary-tui-reference.md b/docs/adr/0002-grok-cli-primary-tui-reference.md new file mode 100644 index 00000000..e8166b96 --- /dev/null +++ b/docs/adr/0002-grok-cli-primary-tui-reference.md @@ -0,0 +1,38 @@ +# Grok CLI is the primary reference for the TUI, painted in DeepSeek blue + +For codsh's interactive terminal UI — the viewport, transcript, composer, +pickers, keys, and slash commands of `codsh-bundle` — Grok CLI is the primary +reference agent. When references disagree on how the TUI behaves, Grok CLI's +behavior is copied. Where Grok CLI has no equivalent, Claude Code decides, then +opencode; Codex CLI and gemini-cli remain corroboration only. This supersedes +ADR-0001 for the TUI. ADR-0001 still arbitrates everything else (headless +output, configuration, sessions below the surface), and its rule stands that +diverging from every reference needs an explicit owner-confirmed rejection in +the Alignment Matrix. + +The owner decided this on 2026-09-29: "对齐 Grok CLI 的用户体验", aligned +from scratch on main. The reference is the locally installed Grok CLI (1.0.41) +and its user guide (`~/.grok/docs/user-guide`). The Grok-derived Rust client +on `rewrite/grok-dsh-20260920` is not an input to this decision. + +## Consequences + +- **Key collisions are rebound, not negotiated.** Where Grok binds a key + differently — Shift+Tab cycling permission modes, Ctrl+O for always-approve, + Ctrl+R for the session picker, Ctrl+G for the tasks pane, Ctrl+Q / Ctrl+D as + a double-press quit, Ctrl+C clearing a draft rather than quitting at an idle + prompt, Esc never cancelling a turn — codsh adopts Grok's binding in its own + batch. The feature a key displaced moves to a new key, and the changeset + says so, because it breaks muscle memory. +- **Visual identity is a recorded divergence.** codsh keeps Grok's theme + *model* — a central theme of named slots in full RGB, quantized per terminal + depth, a `/theme` picker with live preview, `auto` following the background, + a `terminal` theme painting only ANSI colours, and the cursor recoloured with + OSC 12 — but not Grok's palettes. The brand is DeepSeek blue #4D6BFE, with + its tint #7E96F5 for thinking, links, headings, and paths. Grok's own themes + (GrokNight, TokyoNight, …) are not ported. +- **Batches.** The work lands in batches through the standing alignment + pipeline, in dependency order: themes; keymap and permission modes; + composer semantics; visual language; scrollback focus and entry selection; + commands; terminal features. Each opens and closes rows in the Alignment + Matrix's "Grok CLI alignment" section with test anchors. diff --git a/docs/alignment.md b/docs/alignment.md index 241fa6ca..4aef55a6 100644 --- a/docs/alignment.md +++ b/docs/alignment.md @@ -1,16 +1,28 @@ # Alignment Matrix The memory of the alignment pipeline: every interaction/feature gap between -codsh and the reference agents (ADR-0001: Claude Code > opencode > Codex -CLI/gemini-cli as corroboration), and its state. **Done** means every row is -in a terminal state. +codsh and the reference agents, and its state. For the interactive TUI the +order is Grok CLI > Claude Code > opencode > Codex CLI/gemini-cli as +corroboration (ADR-0002); everywhere else Claude Code > opencode > Codex +CLI/gemini-cli (ADR-0001). **Done** means every row is in a terminal state. ## Operating rules - **Discovery**: native Claude Code knowledge seeds the inventory; reading the open-source references (opencode, Codex CLI, gemini-cli) extends it; behavioral probing (driving the real tool in the e2e PTY/VT harness) is - reserved for disputes. + reserved for disputes. For the interactive TUI, Grok CLI's user guide + (`~/.grok/docs/user-guide/*.md`, Grok 1.0.41 installed locally) seeds the + inventory first (ADR-0002). +- **Grok alignment (2026-09-29)**: owner decision, recorded in ADR-0002. Grok + CLI wins TUI ties; keys that collide (Shift+Tab, Ctrl+O, Ctrl+R, Ctrl+G, + Ctrl+Q, Ctrl+C at an idle prompt, Esc Esc) are rebound to Grok's in their + own batch, and the feature a key displaces moves to a new one. The visual + identity is a deliberate divergence: Grok's slot themes, painted in + DeepSeek blue #4D6BFE. Batches, in order: DeepSeek themes and `/theme` + (landed); keymap and permission-mode cycling; composer Esc/Ctrl+C/stash; + visual language; scrollback focus and entry selection; commands; terminal + features. Rows live in "Grok CLI alignment" below. - **Autonomy**: within a batch, implementing, testing, syncing the dev install, changesets, and pushing are autonomous. Work stops for a question only when (a) references conflict beyond the ADR-0001 rule, (b) alignment @@ -77,9 +89,9 @@ in a terminal state. | modifyOtherKeys | probed: claude sets `CSI >4;2m`; codex resets `>4;0m`; opencode sets it | `open` | xterm-proper coverage; kitty path already shipped | | Grapheme clustering (mode 2027) | probed: only opencode sets 2027 | `open` | the terminal-side mode only. The surface's own scans step by grapheme cluster since 2026-09-08 (see Known problems, "Table rule drifts one column"), so what remains is asking the terminal to cluster the same way | | Focus events (mode 1004) | probed: claude, codex, gemini all enable 1004 | `aligned` | keys.spec "reports focus in and out"; console.spec "the bell and focus" suite; pty e2e "focus and background reports". Use: the bell rings only while unfocused; terminals that never report keep always-ring | -| Theme detection (OSC 11) | probed: opencode, codex, gemini query OSC 10/11; claude does not (opencode decides per ADR-0001) | `aligned` | theme.spec setLight/backgroundIsLight; screen.spec entry query; pty e2e light-palette adoption. Scope: the absolute 256-color secondary gray adapts (245→242); base ANSI colors stay the terminal theme's job | +| Theme detection (OSC 11) | probed: opencode, codex, gemini query OSC 10/11; claude does not (opencode decides per ADR-0001); Grok CLI follows the background under `theme = "auto"` | `aligned` | theme.spec setLight/backgroundIsLight, "follows the background under auto and ignores it for a theme chosen by name"; screen.spec entry query; pty e2e light-palette adoption. Scope: under `auto` a light answer swaps the whole palette to `deepseek-light` and repaints the history; a theme chosen by name keeps its own polarity | | OSC 8 hyperlinks | probed: claude emits OSC 8 at startup; gemini/opencode link paths | `open` | markdown links + file:line | -| Segment framing in the transcript | probed 2026-08-20: opencode (1.18.x) draws each message block with `border: ["left"]` plus a `backgroundPanel` fill that becomes `backgroundElement` when focused; claude 2.1.236 carries `borderLeft` with `dashed`/`subtle` variants and `borderLeftDimColor` (e.g. userMessage) and reserves `borderStyle:"round"` boxes for panels. Grok Build styles functional blocks with distinct theme backgrounds (`bg_light` for user prompt bands, `bg_dark` for tool execution surfaces, `bg_thinking` for reasoning deliberation, `diff_insert_bg`/`diff_delete_bg` for diff additions/deletions, `md_code_bg` for code blocks, and `bg_error` for failures) | `aligned` | screen.spec "the rule down a block's edge" suite; transcript.spec "which block a line belongs to" and "grok background differentiation across functional blocks" suites; theme.spec grok background suites; pty e2e "rules each block down its left edge, by what the block is". Functional blocks apply Grok's background palette to differentiate roles while keeping clean block separation, selection highlights, and TTY/NO_COLOR graceful degradation | +| Segment framing in the transcript | probed 2026-08-20: opencode (1.18.x) draws each message block with `border: ["left"]` plus a `backgroundPanel` fill that becomes `backgroundElement` when focused; claude 2.1.236 carries `borderLeft` with `dashed`/`subtle` variants and `borderLeftDimColor` (e.g. userMessage) and reserves `borderStyle:"round"` boxes for panels. Grok Build styles functional blocks with distinct theme backgrounds (`bg_light` for user prompt bands, `bg_dark` for tool execution surfaces, `bg_thinking` for reasoning deliberation, `diff_insert_bg`/`diff_delete_bg` for diff additions/deletions, `md_code_bg` for code blocks, and `bg_error` for failures) | `aligned` | screen.spec "the rule down a block's edge" suite; transcript.spec "which block a line belongs to" and "grok background differentiation across functional blocks" suites; theme.spec grok background suites; pty e2e "rules each block down its left edge, by what the block is". Functional blocks use Grok's background slots, filled from the DeepSeek palette (the person's band is navy #151A30, light #EEF1FB), while keeping clean block separation, selection highlights, and TTY/NO_COLOR graceful degradation | | Sticky turn headers | Grok Build pins the user prompt owning the visible response, shrinks a multi-row header while it crosses the top, and lets the next prompt push it away ([source at owner-selected commit](https://github.com/xai-org/grok-build/blob/bc7f02eddd3d84085849dc19ed216f11c23b0571/crates/codegen/xai-grok-pager/src/scrollback/sticky.rs)). Recorded as an owner-confirmed Surface addition beyond the four Reference Agents | `aligned` | sticky.spec pure layout suite; screen.spec prompt grouping/fold/resize/trim/search/click suites; transcript.spec real-user boundary cases; experience e2e "keeps the owning prompt above each turn while scrolling across the boundary". codsh keeps its `›`, user colour, heavy Rule, floating panel background fill (one padding row of that fill above and below the prompt, so the pinned copy reads as a panel rather than text pressed against the top of the screen), bottom divider line, and existing scroll notice — which sits at the foot of the viewport and clicks back to the tail (screen.spec "puts the scrollback notice under what is being read", "returns to the latest when the notice row is clicked"; experience e2e "says how far back it is, at the foot of the screen, and clicks home"). Prompts cap at three visual rows; an expanded long prompt remains a turn boundary but is temporarily non-sticky. The header copy is display-only and never enters search, selection, transcript, or pipe output | | Semantic turn navigation | Grok Build exposes turn jumps and a reversible `/jump` preview over real user prompts | `aligned` | keys.spec legacy/Kitty Shift+Left/Right; screen.spec retained turn index and viewport restore; prompt.spec selector preview; experience e2e shifted-arrow turn crossing. Plugin context never becomes a turn and pipe mode owns no viewport | | Prompt-top anchoring | Grok Build's page-flip behavior places a submitted prompt at the viewport top while reply rows consume the space beneath it | `aligned` | screen.spec anchor lifecycle/streaming/resize/replay cases plus the return cases (step off row by row, wheel and PgDn back onto the anchored frame, streaming into the gap under a scrolled reader, next prompt taking the gap over); experience e2e paced streaming capture and "gives the anchored prompt back when the reader wheels home again". codsh models the gap as display-only tail rows that scroll with the transcript: search, selection, folds, scrollback trimming, replay, and pipes never observe them, and the gap ends only when the reply fills the viewport or the next prompt takes it over | @@ -118,7 +130,7 @@ in a terminal state. | Forward kill-word (Alt+d) | emacs in the reference composers | `open` | rest surface; only backward kill-word is bound | | Double-click / keyboard selection | typical in the reference TUIs | `open` | rest surface; selection is press-drag-release only | | Idle keybinding footer | claude, opencode, Codex always-on key chrome | `aligned` | prompt.spec "the key legend" suite (on the hint row empty or typing, replaced by a hint or a flash and given back at the same chrome height, absent under a selector); experience e2e "keeps the key legend under the box while typing" (legend row at the same index in the empty and typing frames, placeholder gone). Shape: `? shortcuts · ⇧Tab plan · Ctrl+T todos · Ctrl+O folds` is the hint row's fallback, so the row is always present while the box is up and everything that borrows it — flash, find, hover, hint — replaces rather than adds; the box never jumps, and a hint appearing no longer changes the chrome height as it did before. Batch item 4, 2026-09-04 | -| /theme picker | opencode, Codex | `open` | rest surface; OSC 11 only remaps secondary gray, no catalog | +| /theme picker | Grok CLI `/theme`: arrows preview live, Enter applies and saves, Esc reverts, `/theme ` sets directly, `auto` follows the background, a `terminal` theme paints only ANSI colours with no backgrounds, OSC 12 recolours the cursor (OSC 112 on exit), `GROK_THEME` overrides; opencode, Codex corroborate | `aligned` | theme.spec "the theme catalog"; theme-setting.spec; prefs.spec; theme-picker.spec; prompt.spec "tells the caller the outcome before the frame that closes the selection"; screen.spec "theme-driven fills"; banner.spec "the mark, by theme and depth"; transcript.spec "the person's message under a theme that paints no bands"; pty e2e "switches to deepseek-light with /theme, repaints the history, and keeps it across a restart", "previews a theme in the picker and puts the original back on Esc". Scope: catalog `auto`/`deepseek`/`deepseek-light`/`terminal` (DeepSeek blue replaces Grok's own themes, ADR-0002); `CODSH_THEME` for one launch; kept in `code-cli-ui.json`; history repainted by replay, deferred under a running turn or a Child view | | Desktop notifications | claude, Codex (osascript / notify-send / OSC 9) | `aligned` | notify.spec (one-line text with control characters dropped and a cap, the plan per terminal — OSC 9 alone for iTerm2/WezTerm/Ghostty/kitty/Windows Terminal, `osascript` instead for Terminal.app with AppleScript quoting, `notify-send` beside on an unknown Linux terminal, the sequence alone elsewhere — and a command whose failure is ignored); console.spec "notifications through the terminal" (OSC 9 only while away, nothing off a TTY); pty e2e "notifies through the terminal while the window is unfocused, naming what waits" and the focused test asserting no OSC 9. Triggers are the bell's: a decision waiting (approval, named by `nameCall`; a question) and a turn over 10 s ending, gated by `console.away` (focus reported out or never reported, as the bell reads it) and the `notify` config beside `bell`. The e2e harness pins `TERM_PROGRAM=codsh-e2e` so a suite never reaches the runner's desktop. Batch item 5, 2026-09-04 | | Session rename | claude, opencode session lists | `open` | codsh-side: `SessionTitleService.rename` exists; `/rename ` over the current session. Not batched | | Session delete | claude, opencode session lists | `upstream` | dsh-session-persistence exposes create/append/load/inspect/readFrom/list/locate/repair and no delete; JSONL truncation exists only as crash repair. `/resume` lists but cannot delete | @@ -150,6 +162,28 @@ in a terminal state. | Headless JSON output | claude `--output-format json`; Codex `--json`; gemini | `open` | low. `-p` prints text and exits after one turn. Not a target-user scenario; recorded, not batched | | Headless streaming | claude `stream-json`; Codex exec | `open` | low. Same | +## Grok CLI alignment + +Rows opened by the 2026-09-29 decision (ADR-0002). Reference: Grok CLI 1.0.41, +`~/.grok/docs/user-guide` (03 keyboard shortcuts, 04 slash commands, 06 +theming, 19 plan mode, 25 status line). Batch order as in "Grok alignment" +above; a row closes with test anchors like any other. + +| Item | Reference behavior | State | Tests / Notes | +|---|---|---|---| +| DeepSeek themes and `/theme` | Grok slot themes, picker with live preview, `auto`, `terminal`, OSC 12 cursor | `aligned` | See the "/theme picker" row above. Batch 1 | +| Permission-mode cycling | Shift+Tab cycles Normal → Plan → (Auto) → Always-approve; Ctrl+O toggles always-approve; a mode flag shows on the status row (03:255, 22:31-61) | `open` | Batch 2. Today Shift+Tab only toggles plan and presets change through `/permission`. Displaces Ctrl+O (all folds) | +| Keymap rebinds | Ctrl+R session picker; Ctrl+G tasks/subagents pane; Ctrl+; queue pane; Ctrl+Q / Ctrl+D quit on a double press; Ctrl+T todos (03:245-277, 03:339-351) | `open` | Batch 2. Moves codsh's Ctrl+R history search (→ `/history`, ↑ on an empty prompt), Ctrl+H subagents, Ctrl+Q queue, Ctrl+G panorama | +| Composer Esc / Ctrl+C semantics | Esc never cancels a turn ("Press Ctrl+c to cancel the turn"); Ctrl+C clears a draft, then cancels; idle Esc Esc clears and stashes the draft, or opens rewind on an empty prompt; Ctrl+S stash / pop (03:223-237, 03:262) | `open` | Batch 3. Today Ctrl+C at an idle prompt quits and Esc Esc recalls the last prompt | +| Composer footer | `Enter:send` / `Enter:queue` plus the newline chord once the draft is non-empty (03:467-471) | `open` | Batch 3 | +| Tool and thinking blocks | `◆` tool bullets, a `Thinking…` head with an animated accent, shell blocks labelled `Run` showing the first 2 / last 3 output lines, `+N/-M` on collapsed edits, `›` on expandable entries (06:238-294) | `open` | Batch 4 | +| Still-running line and turn marker | `◎ 1 command · 1 subagent still running`; "Worked for Xs" after a turn (20:185-201) | `open` | Batch 4 | +| Status line items | `cwd │ model │ N% ctx`, context turns amber at the compact threshold, optional cost and turn timer (25:9-24) | `open` | Batch 4 | +| Scrollback focus and entry selection | Tab moves focus between prompt and scrollback; ↑/↓ select an entry, ←/→ fold, Enter / Ctrl+F open the viewer, `y`/`Y` copy, `r` raw markdown, Ctrl+U/D half page (03:31-100) | `open` | Batch 5. Depends on batches 2–4 | +| Session and context commands | `/context`, `/session-info` (alias of `/status`), `/rename`, `/export`, `/fork`, `/history`, `/edit-prompt`, `/compact-mode`, `/timestamps`, `/release-notes`, `/doctor` (04) | `open` | Batch 6; several duplicate rows above (rename, export, /context) and close them together | +| Command palette | Ctrl+P or `?` lists every command with its key or slash form (03:396-400) | `open` | Batch 6 | +| Terminal features | OSC 8 links, OSC 9;4 tab progress, tab-title items (05:458-503, 25:55) | `open` | Batch 7; OSC 8 also closes the row above | + ## Known problems Implementation defects, as distinct from alignment gaps: the evidence is a diff --git a/e2e/capture.e2e.ts b/e2e/capture.e2e.ts index dfd938d2..9e3f8d6a 100644 --- a/e2e/capture.e2e.ts +++ b/e2e/capture.e2e.ts @@ -145,6 +145,8 @@ describe.skipIf(process.env.CAPTURE_SCREENS === undefined)('showcase frames', () cwd: WORKSPACE, rows: CAPTURE_ROWS, columns: CAPTURE_COLUMNS, + // The site shows the palette as designed: DeepSeek blue in full RGB. + env: { COLORTERM: 'truecolor' }, }) // The suites' screen helpers are fixed to their own window, so the frame // is cut here instead: at the end of the synchronized update the marker diff --git a/e2e/harness.ts b/e2e/harness.ts index 727e0fa1..be8a8fe3 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -266,11 +266,14 @@ export function resolveLaunch(options: { if (!existsSync(join(bundleRoot, 'lib', 'index.js'))) { throw new Error('codsh e2e needs the built lib/ — run `pnpm run build` first (or use `pnpm run test:e2e`)') } + // Dropped: the palette forks on COLORTERM (truecolor paints the brand as + // RGB), and a runner's own theme choice must not reach a suite. + const { COLORTERM: _colorterm, CODSH_THEME: _theme, ...inherited } = process.env return { command: process.execPath, args: [dshBin(), '--profile', 'code', '--patch', options.overlay, ...options.args ?? []], env: { - ...process.env, + ...inherited, // Pinned: rendering assertions must not depend on the runner's TERM — // CI machines report no 256-color support and the palette forks on it. TERM: 'xterm-256color', diff --git a/e2e/pty-driver.ts b/e2e/pty-driver.ts index 5d723b3f..84c98141 100644 --- a/e2e/pty-driver.ts +++ b/e2e/pty-driver.ts @@ -68,6 +68,10 @@ def resolve(payload, output): node, launch_args_json, launch_env_json, cwd, timeout_seconds, script_json, win_rows, win_cols = sys.argv[1:] env = os.environ.copy() +# The runner's colour depth and theme are not the suite's; a test that wants +# one passes it explicitly, and the update below then wins. +env.pop("COLORTERM", None) +env.pop("CODSH_THEME", None) env.update(json.loads(launch_env_json)) env.pop("NO_COLOR", None) script = [(m.encode(), p.encode(), int(d)) for m, p, d in json.loads(script_json)] diff --git a/e2e/pty-selectors.e2e.ts b/e2e/pty-selectors.e2e.ts index d05523f6..51c3adea 100644 --- a/e2e/pty-selectors.e2e.ts +++ b/e2e/pty-selectors.e2e.ts @@ -56,9 +56,9 @@ describe.skipIf(process.platform === 'win32')('approvals and selectors (real PTY const plain = output.replaceAll(/\u001B\[[0-9;?]*[A-Za-z]/gu, '') expect(plain).toContain('▲ plan mode') expect(plain).toContain('▼ plan mode off') - // Plan mode paints the frame with warn (amber on 256-color, bright yellow otherwise); idle focus uses accent cyan. + // Plan mode paints the frame with warn (amber on 256-color, bright yellow otherwise); idle focus uses the DeepSeek blue accent. expect(output).toMatch(/\u001B\[(?:38;5;172|93)m╭/) - expect(output).toContain('\u001B[36m╭') + expect(output).toContain('\u001B[38;5;63m╭') }, E2E_TEST_TIMEOUT_MS) it('switches the model through the /model selector, all the way to the request', async () => { diff --git a/e2e/pty-theme.e2e.ts b/e2e/pty-theme.e2e.ts new file mode 100644 index 00000000..20cce795 --- /dev/null +++ b/e2e/pty-theme.e2e.ts @@ -0,0 +1,105 @@ +/** + * The theme on a real terminal: `/theme` switches the palette — the chrome at + * once, the conversation above it repainted — keeps the choice across a + * restart without disturbing the other preferences, and the picker previews a + * theme and puts the original back on Esc. + */ + +import { readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { E2E_TEST_TIMEOUT_MS, makeHome } from './harness.ts' +import { drivePtySteps } from './pty-driver.ts' +import { ENTER, ESCAPE } from './pty-helpers.ts' + +/** The DeepSeek blue accent on the pinned 256-colour terminal, as the input frame opens. */ +const BRAND_FRAME = '\u001B[38;5;63m╭' + +/** + * The bytes of a capture between two step offsets. Offsets count bytes, and + * the capture is a string of UTF-16 units, so the cut goes through a buffer. + */ +const cut = (output: string, from: number | undefined, to?: number): string => + Buffer.from(output).subarray(from ?? 0, to).toString() + +/** The prefs file a home keeps, parsed; empty when there is none. */ +async function prefsOf(home: string): Promise<Record<string, unknown>> { + try { + return JSON.parse(await readFile(join(home, 'code-cli-ui.json'), 'utf8')) as Record<string, unknown> + } catch { + return {} + } +} + +describe.skipIf(process.platform === 'win32')('the theme (real PTY)', () => { + it('switches to deepseek-light with /theme, repaints the history, and keeps it across a restart', async () => { + const home = await makeHome() + try { + // A preference the theme must not erase. + await writeFile(join(home, 'code-cli-ui.json'), '{"density":"comfortable"}\n') + const first = await drivePtySteps('write', [ + ['Welcome to codsh', `note the work${ENTER}`, 500], + ['Write note.txt', `/theme deepseek-light${ENTER}`, 600], + ['theme · deepseek-light', `/exit${ENTER}`, 400], + ], { env: { DSH_HOME: home } }) + + const before = cut(first.output, 0, first.offsets[1]) + // Painted dark first: the navy band behind the person's message, and the brand frame. + expect(before).toContain('\u001B[48;5;17m') + expect(before).toContain(BRAND_FRAME) + expect(before).toContain('\u001B]12;#4d6bfe\u0007') + // The turn may still be painting when the command is typed, so the + // switch is found by its first light band. From there the history is + // repainted: no dark band is ever painted again, the secondary gray is + // the light one, and the frame keeps the brand blue both themes share. + const sent = cut(first.output, first.offsets[1]) + const switchedAt = sent.indexOf('\u001B[48;5;189m') + expect(switchedAt).toBeGreaterThan(-1) + const after = sent.slice(switchedAt) + expect(after).not.toContain('\u001B[48;5;17m') + expect(after).toContain('\u001B[38;5;242m') + expect(after).toContain(BRAND_FRAME) + expect(after).toContain('note the work') + expect(await prefsOf(home)).toEqual({ density: 'comfortable', theme: 'deepseek-light' }) + + const second = await drivePtySteps('write', [ + ['Welcome to codsh', `/theme terminal${ENTER}`, 600], + ['theme · terminal', `/exit${ENTER}`, 400], + ], { env: { DSH_HOME: home } }) + // The saved theme is in force from the first frame, with no background answer to go on. + const booted = cut(second.output, 0, second.offsets[0]) + expect(booted).toContain('\u001B[38;5;242m') + expect(booted).not.toContain('\u001B[38;5;245m') + // The terminal theme frames the box in the terminal's own blue and gives the cursor its colour back. + const switched = cut(second.output, second.offsets[0]) + expect(switched).toContain('\u001B[34m╭') + expect(switched).toContain('\u001B]112\u0007') + expect(await prefsOf(home)).toEqual({ density: 'comfortable', theme: 'terminal' }) + } finally { + await rm(home, { recursive: true, force: true }) + } + }, E2E_TEST_TIMEOUT_MS) + + it('previews a theme in the picker and puts the original back on Esc', async () => { + const home = await makeHome() + try { + const run = await drivePtySteps('write', [ + ['Welcome to codsh', `/theme${ENTER}`, 600], + // Three rows down is the terminal theme: the marker takes the terminal's own blue. + ['Theme', `${ESCAPE}[B${ESCAPE}[B${ESCAPE}[B`, 600], + ['\u001B[34m❯', ESCAPE, 600], + ['theme · auto → deepseek', `/exit${ENTER}`, 400], + ], { env: { DSH_HOME: home } }) + + const previewed = cut(run.output, run.offsets[1], run.offsets[2]) + expect(previewed).toContain('\u001B[34m❯') + const reverted = cut(run.output, run.offsets[2]) + expect(reverted).toContain(BRAND_FRAME) + expect(reverted).not.toContain('\u001B[34m╭') + // Dismissed, nothing was kept. + expect((await prefsOf(home)).theme).toBeUndefined() + } finally { + await rm(home, { recursive: true, force: true }) + } + }, E2E_TEST_TIMEOUT_MS) +}) diff --git a/packages/bundle/src/banner.ts b/packages/bundle/src/banner.ts index dc0a1ce1..2d76769a 100644 --- a/packages/bundle/src/banner.ts +++ b/packages/bundle/src/banner.ts @@ -4,7 +4,7 @@ */ import { displayWidth, truncate } from './theme.ts' -import type { Theme } from './theme.ts' +import type { Slot, Theme } from './theme.ts' /** * Half-block sprite of `assets/logo.svg`: a › chevron, a hull, a wave, and a @@ -35,19 +35,25 @@ const LOGO_WIDTH = SPRITE[0]!.length + 1 /** One sprite pixel. */ type Pixel = '.' | 'c' | 'h' | 'w' -/** Truecolor fills matching `assets/logo.svg`, used only on a coloured TTY. */ -const FILL: Record<Exclude<Pixel, '.'>, string> = { - c: '\u001B[38;2;126;150;245m', - h: '\u001B[38;2;238;241;251m', - w: '\u001B[38;2;61;86;214m', +/** + * The theme slot each pixel is inked with, matching `assets/logo.svg` in the + * DeepSeek themes: tint chevron, brand-blue water, a hull that stays visible + * on either background — and falling back by depth like every other colour. + */ +const INK: Record<Exclude<Pixel, '.'>, Slot> = { + c: 'logo_chevron', + h: 'logo_hull', + w: 'logo_water', } /** * Paint one half-block cell from a pair of pixels. */ function paintCell(upper: Pixel, lower: Pixel, theme: Theme): string { - const ink = (role: Exclude<Pixel, '.'>, ch: string): string => - theme.colored ? `${FILL[role]}${ch}\u001B[0m` : ch + const ink = (role: Exclude<Pixel, '.'>, ch: string): string => { + const seq = theme.sgr(INK[role]) + return seq === '' ? ch : `${seq}${ch}\u001B[0m` + } if (upper === '.') return lower === '.' ? ' ' : ink(lower, '▄') if (lower === '.') return ink(upper, '▀') if (upper === lower) return ink(upper, '█') @@ -133,7 +139,7 @@ function fitSessionId(session: string, columns: number): string { */ function returningLines(facts: BannerFacts, theme: Theme, columns: number): string[] { const line1 = truncate( - `${theme.agent('✻')}${theme.muted(` codsh · ${facts.model} · /help`)}`, + `${theme.accent('✻')}${theme.muted(` codsh · ${facts.model} · /help`)}`, columns, ) const id = fitSessionId(facts.session, columns) @@ -147,7 +153,7 @@ function returningLines(facts: BannerFacts, theme: Theme, columns: number): stri function firstLines(facts: BannerFacts, theme: Theme, columns: number): string[] { const composition = compositionOf(facts) const welcome = truncate( - `${theme.agent('✻')}${theme.muted(` Welcome to codsh · ${composition}`)}`, + `${theme.accent('✻')}${theme.muted(` Welcome to codsh · ${composition}`)}`, columns, ) const tips = truncate( @@ -165,7 +171,7 @@ function firstLines(facts: BannerFacts, theme: Theme, columns: number): string[] /** * Render the opening banner. * @param facts - what to report. - * @param theme - styling; `✻` via `agent`, everything else `muted`. + * @param theme - styling; `✻` in the brand accent, everything else `muted`. * @param columns - display columns available; no line may exceed them. * @returns the lines to print (empty when `welcomeKind` is `none`). */ diff --git a/packages/bundle/src/console.ts b/packages/bundle/src/console.ts index 23d2af17..4e44f0a2 100644 --- a/packages/bundle/src/console.ts +++ b/packages/bundle/src/console.ts @@ -24,6 +24,7 @@ import type { HoverBlock, TurnReference, ViewportBookmark } from './screen.ts' import { GUTTER, Screen } from './screen.ts' import type { TerminalGraphic } from './terminal-graphics.ts' import type { Key } from './keys.ts' +import type { Theme } from './theme.ts' /** Columns assumed when the output stream reports none (a pipe). */ const FALLBACK_COLUMNS = 80 @@ -860,6 +861,19 @@ export class TerminalConsole { this.screen?.setLight(light) } + /** + * Paint the screen's own fills with the renderer's theme. + * @param theme - the theme the rest of the surface paints with. + */ + useTheme(theme: Theme): void { + this.screen?.useTheme(theme) + } + + /** Repaint the whole frame after the theme changed. */ + restyle(): void { + this.screen?.restyle() + } + /** * Set the terminal window title. * @param title - the title text; control bytes are the terminal's to reject. diff --git a/packages/bundle/src/density.ts b/packages/bundle/src/density.ts index 1bd745a7..cc299851 100644 --- a/packages/bundle/src/density.ts +++ b/packages/bundle/src/density.ts @@ -9,7 +9,9 @@ * @module codsh-bundle/src/density */ -import { readFile, writeFile } from 'node:fs/promises' +import { mergePrefs, readPrefs } from './prefs.ts' + +export { UI_PREFS_FILE } from './prefs.ts' /** The two densities `/ui` switches. */ export type Density = 'compact' | 'comfortable' @@ -17,9 +19,6 @@ export type Density = 'compact' | 'comfortable' /** Returning-user default: tight chrome, one-row cards. */ export const DEFAULT_DENSITY: Density = 'compact' -/** Filename under the dsh home, beside `code-cli-history.json`. */ -export const UI_PREFS_FILE = 'code-cli-ui.json' - /** * Diff lines before a click opens the pager rather than expanding in place. * @@ -54,24 +53,19 @@ export function densityReport(density: Density): string { /** * Read a persisted density from a prefs file. - * @param path - JSON file `{ "density": "compact" | "comfortable" }`. + * @param path - JSON file carrying `"density": "compact" | "comfortable"`. * @returns the saved mode, or undefined when missing or unreadable. */ export async function loadDensity(path: string): Promise<Density | undefined> { - try { - const parsed: unknown = JSON.parse(await readFile(path, 'utf8')) - if (parsed === null || typeof parsed !== 'object' || !('density' in parsed)) return undefined - return parseDensity(String((parsed as { density: unknown }).density)) - } catch { - return undefined - } + const { density } = await readPrefs(path) + return density === undefined ? undefined : parseDensity(String(density)) } /** - * Persist the live density for the next session. - * @param path - JSON file to overwrite. + * Persist the live density for the next session, keeping the file's other keys. + * @param path - the prefs file. * @param density - the mode to keep. */ export async function saveDensity(path: string, density: Density): Promise<void> { - await writeFile(path, `${JSON.stringify({ density })}\n`) + await mergePrefs(path, { density }) } diff --git a/packages/bundle/src/index.ts b/packages/bundle/src/index.ts index 3a27fb60..43fa5457 100644 --- a/packages/bundle/src/index.ts +++ b/packages/bundle/src/index.ts @@ -55,6 +55,7 @@ import { SubagentRoster, outcomeStatus, subagentTitle, subagentsReport } from '. import type { ToolWorkflowAgentStartData } from '@deepseek-ai/dsh-tool-workflow/types' import type { QueueItem } from './queue.ts' import { bannerLines, resolveWelcomeKind } from './banner.ts' +import type { BannerFacts } from './banner.ts' import { createCompleter, expandSkillGestures, fuzzyScore } from './completion.ts' import { expandTemplate, loadCustomCommands } from './custom-commands.ts' import { styleDiffLine } from './diff.ts' @@ -114,7 +115,9 @@ import type { TodoList } from './todos.ts' import type { StatusFacts } from './status.ts' import { backgroundIsLight, createTheme, truncate } from './theme.ts' import { FOLD_LABELS, Transcript, blockRules, presentAskUserQuestionResult, runnerNotice, thinkingFold, thinkingFoldRules, thinkingOpenRows } from './transcript.ts' -import type { Theme } from './theme.ts' +import type { Theme, ThemeSetting } from './theme.ts' +import { HistoryRepaint, pickTheme } from './theme-picker.ts' +import { THEME_CHOICES, choiceList, loadThemeSetting, parseThemeSetting, saveThemeSetting, startupThemeSetting, themeReport } from './theme-setting.ts' /** Stable Cordis plugin name. */ export const name = 'coding-cli-runner' @@ -628,7 +631,21 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { const sessions = ctx.get('sessions') if (sessions === undefined) return const cwd = process.cwd() - const theme = createTheme(io.console.isTty, process.env) + const uiPrefsPath = dshHomePath(UI_PREFS_FILE) + const startTheme = startupThemeSetting(process.env, await loadThemeSetting(uiPrefsPath)) + const theme = createTheme(io.console.isTty, process.env, startTheme.setting) + // The screen's own fills — hover, the sticky header, the divider — and the + // cursor colour paint with this same theme, so a switch reaches them all. + io.console.useTheme(theme) + /** + * The palette the transcript above the prompt was painted in. Painted lines + * keep their colours, so a theme change leaves it stale until repainted. + */ + let paintedPalette = theme.resolved + /** What the opening banner showed, so a repaint can show it again; none after a resume or a switch. */ + let bannerFacts: BannerFacts | undefined + /** Reacts to a palette change: the chrome now, the history when it is safe. Assigned once both exist. */ + let onThemeChanged = (): void => { io.console.restyle() } /** * Whether a block appended now wants a blank above it. * @@ -665,20 +682,18 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { gapBefore(notice.rule) prompt.write(notice.line, notice.rule) } - // The viewport asks OSC 11 on entry; a light answer swaps in the readable - // secondary-text shade for everything rendered from then on. + // The viewport asks OSC 11 on entry; under `auto`, a light answer swaps in + // the light palette — chrome at once, the history repainted to match. io.console.onBackground((payload) => { const light = backgroundIsLight(payload) - if (light !== undefined) { - theme.setLight(light) - io.console.setLight(light) - } + if (light !== undefined && theme.setLight(light)) onThemeChanged() }) // Before the roster resolves anything: discovery re-reads its roots on every // call, so a preset placed here is visible to the resolve below. const preset = await installPackagedPreset() if (preset.installed) io.console.write(theme.dim(`installed preset into ${preset.path}`), blockRules(theme).meta) + if (startTheme.warning !== undefined) io.console.write(theme.dim(` ${startTheme.warning}`), blockRules(theme).meta) const composed = await compose(ctx, config, cwd) if (composed === undefined) { @@ -690,7 +705,6 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { // `/clear` and `/resume` swap the session under a running surface, so the // agent, its handle, and its presenter-bound transcript live in one mutable // ref that every closure reads through. - const uiPrefsPath = dshHomePath(UI_PREFS_FILE) let density: Density = await loadDensity(uiPrefsPath) ?? DEFAULT_DENSITY const live = { handle: composed.handle, @@ -783,14 +797,14 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { // Replay owns the screen; skip the welcome entirely. replay(live.agent.session, live.transcript, io, theme) } else { - const welcomeKind = resolveWelcomeKind(false) - for (const line of bannerLines({ + bannerFacts = { model, preset: presetId, session: live.agent.session.id, readsKeys: io.console.readsKeys, - welcomeKind, - }, theme, io.console.contentColumns)) io.console.write(line, blockRules(theme).meta) + welcomeKind: resolveWelcomeKind(false), + } + for (const line of bannerLines(bannerFacts, theme, io.console.contentColumns)) io.console.write(line, blockRules(theme).meta) } // The version this build carries, and — off the boot's critical path — one @@ -890,7 +904,7 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { // `/init` and `/ship` built in, plus whatever command files the person defined. const custom = await loadCustomCommands( [dshHomePath('commands'), join(cwd, '.dsh', 'commands')], - new Set([...(commands?.list(live.agent) ?? []).map(entry => entry.name), 'exit', 'quit', 'help', 'init', 'ship', 'status', 'model', 'thinking', 'effort', 'clear', 'resume', 'diff', 'jump', 'copy', 'view']), + new Set([...(commands?.list(live.agent) ?? []).map(entry => entry.name), 'exit', 'quit', 'help', 'init', 'ship', 'status', 'model', 'thinking', 'effort', 'clear', 'resume', 'diff', 'jump', 'copy', 'view', 'ui', 'theme']), ) for (const warning of custom.warnings) io.console.write(theme.dim(` skipped ${warning}`), blockRules(theme).meta) const customByName = new Map(custom.commands.map(command => [command.name, command])) @@ -934,6 +948,9 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { const current = presets.current(live.agent.session) return offer(presets.names.map(name => ({ value: name, detail: name === current ? 'current' : '' }))) } + if (command === 'theme') { + return offer(THEME_CHOICES.map(choice => ({ value: choice.name, detail: choice.name === theme.setting ? 'current' : choice.detail }))) + } if (command === 'ui') { return offer([ { value: 'compact', detail: density === 'compact' ? 'current' : 'tight chrome' }, @@ -1273,6 +1290,49 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { return { kind: 'success', text: densityReport(next) } }, })) + disposers.push(commands.register({ + name: 'theme', + description: 'colour theme: auto (default), deepseek, deepseek-light, terminal', + input: { hint: '[auto|deepseek|deepseek-light|terminal]' }, + handler: async ({ rawInput, signal }) => { + const typed = rawInput.trim() + // Kept for the next boot; a failed save still switches this session. + const keep = async (setting: ThemeSetting): Promise<void> => { + try { + await saveThemeSetting(uiPrefsPath, setting) + } catch { + // The next boot falls back to the last readable file. + } + } + // On a terminal the answer is a flash: the command writes nothing a + // repaint of the history would wipe. + const report = (): { kind: 'success'; text?: string } => { + if (!io.console.readsKeys) return { kind: 'success', text: themeReport(theme) } + prompt.setFlash(theme.dim(` ${themeReport(theme)}`)) + return { kind: 'success' } + } + if (typed !== '') { + const next = parseThemeSetting(typed) + if (next === undefined) return { kind: 'error', text: `unknown theme "${typed}" — ${choiceList()}` } + applyTheme(next) + historyRepaint.request() + await keep(next) + return report() + } + if (!io.console.readsKeys) { + const catalog = THEME_CHOICES.map(choice => ` ${choice.name.padEnd(15)}${choice.detail}`) + return { kind: 'success', text: [themeReport(theme), ...catalog].join('\n') } + } + const chosen = await pickTheme({ + select: (spec, abort, preview, settled) => prompt.select(spec, abort, preview, settled), + current: () => theme.setting, + apply: applyTheme, + history: historyRepaint, + }, signal) + if (chosen !== undefined) await keep(chosen) + return report() + }, + })) disposers.push(commands.register({ name: 'update', description: 'check for a newer codsh and install it', @@ -1529,13 +1589,14 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { const next = await composed.createAnother() await switchTo(next, false) // /clear resets the screen with the full ASCII logo banner. - for (const line of bannerLines({ + bannerFacts = { model, preset: presetId, session: live.agent.session.id, readsKeys: io.console.readsKeys, welcomeKind: 'first', - }, theme, io.console.contentColumns)) prompt.write(line, blockRules(theme).meta) + } + for (const line of bannerLines(bannerFacts, theme, io.console.contentColumns)) prompt.write(line, blockRules(theme).meta) paintRunnerFolds(live.transcript) return { kind: 'success', text: `new session ${live.agent.session.id}` } }, @@ -1983,21 +2044,58 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { if (replace) io.console.clearScreen() io.console.suspendPainting() try { - replayEvents(session, transcript, io, theme, childOwnedEvents(session.snapshotEvents(), session.inheritedEventCount)) - // A child still waiting for its result is bound to the card its own - // log names, the way the start edge binds it: store order is not - // call order when two started in one step. - for (const child of sessions.list()) { - if (child.header.parentSession !== session.id) continue - const lines = transcript.promotePendingView(child.id, descriptorLabel(childOwnedEvents(child.snapshotEvents(), child.inheritedEventCount))) - if (lines.length === 0) continue - io.console.appendFold(lines, lines, transcript.takeRule(), transcript.takeLabel(), transcript.takeEnter(), undefined, transcript.takePendingCard()) - } - paintRunnerFolds(transcript) + pourSession(session, transcript, childOwnedEvents(session.snapshotEvents(), session.inheritedEventCount)) } finally { io.console.resumePainting() } } + /** + * Render a Session's events into a transcript, with the cards its pending + * children and runners need. The caller holds painting. + * @param session - the Session being shown. + * @param transcript - the renderer that will own later events for it. + * @param events - which of its events to render. + */ + const pourSession = (session: ShownSession, transcript: Transcript, events: readonly SessionEvent[]): void => { + replayEvents(session, transcript, io, theme, events) + // A child still waiting for its result is bound to the card its own + // log names, the way the start edge binds it: store order is not + // call order when two started in one step. + for (const child of sessions.list()) { + if (child.header.parentSession !== session.id) continue + const lines = transcript.promotePendingView(child.id, descriptorLabel(childOwnedEvents(child.snapshotEvents(), child.inheritedEventCount))) + if (lines.length === 0) continue + io.console.appendFold(lines, lines, transcript.takeRule(), transcript.takeLabel(), transcript.takeEnter(), undefined, transcript.takePendingCard()) + } + paintRunnerFolds(transcript) + } + /** + * Repaint the conversation above the prompt in the live palette. + * + * Painted lines keep the colours they were painted in, so a theme change + * replays the session into a fresh transcript — the way `--resume` paints + * one — keeping the reading position. Only the rendering is rebuilt: the + * session, its approvals, roster, queue, and clocks are untouched. What + * never reached the session log (command reports, `!` output, notices) + * is not repainted, exactly as a resume does not show it. + * @returns how long it took, in milliseconds. + */ + const runRepaint = (): number => { + const started = performance.now() + const bookmark = io.console.captureViewportBookmark() + io.console.suspendPainting() + try { + io.console.clearScreen() + live.transcript = new Transcript({ theme, columns: () => io.console.contentColumns, cwd, density, gapWanted }, presentersFor(ctx, live.agent)) + if (bannerFacts !== undefined) for (const line of bannerLines(bannerFacts, theme, io.console.contentColumns)) io.console.write(line, blockRules(theme).meta) + pourSession(live.agent.session, live.transcript, live.agent.session.snapshotEvents()) + io.console.restoreViewportBookmark(bookmark) + } finally { + io.console.resumePainting() + } + paintedPalette = theme.resolved + return performance.now() - started + } /** * Reconstruct runner Child view Folds from live Sessions that name a graph * key. A leftover worktree with no Session is not a door. Off a TTY, none. @@ -2020,6 +2118,33 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { } } if (config.resume !== '') paintRunnerFolds(live.transcript) + // A theme change repaints the history only when nothing is in flight: a + // running turn streams into the live transcript, and a Child view covers it. + const historyRepaint = new HistoryRepaint({ + stale: () => io.console.readsKeys && paintedPalette !== theme.resolved, + busy: () => live.agent.status === 'running' || childViews.current !== undefined, + run: runRepaint, + }) + /** Repaint everything composed per frame — the chrome, modals, the status row — in the live palette. */ + const applyChrome = (): void => { + io.console.restyle() + prompt.restyle() + refreshStatus() + } + /** + * Put a theme choice in force for everything painted from now on. + * @param next - the choice. + * @returns whether the palette changed. + */ + const applyTheme = (next: ThemeSetting): boolean => { + if (!theme.setTheme(next)) return false + applyChrome() + return true + } + onThemeChanged = (): void => { + applyChrome() + historyRepaint.request() + } /** * Open a child subagent's transcript on top of whatever is showing. * @param id - the child session the card named. @@ -2091,6 +2216,8 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { spinner.start() } startThinkingPulse() + // Back on the parent, a history repaint the view held off can run. + if (childViews.current === undefined) historyRepaint.flush() } /** * One event of a child's own log, folded into its roster row: a tool call @@ -2653,6 +2780,9 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { live.agent = next.agent prompt.setTodos(todoList(ctx, live.agent), true) live.transcript = new Transcript({ theme, columns: () => io.console.contentColumns, cwd, density, gapWanted }, presentersFor(ctx, next.agent)) + bannerFacts = undefined + paintedPalette = theme.resolved + historyRepaint.cancel() // The viewport buffer is the RETIRED session's transcript; left in place, // /clear would clear nothing visible and /resume would replay under it. io.console.clearScreen() @@ -3105,12 +3235,17 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { shownStatus = status } } + // A theme change during the last turn repainted only the chrome; the + // history catches up now that nothing streams into it. + historyRepaint.flush() const line = await prompt.read() if (line === undefined) break // The line's pasted images, drained exactly once beside it. const images = prompt.takeAttachments() const trimmed = line.trim() - const surfaceOnlyView = /^\/view(?:\s|$)/u.test(trimmed) + // Chrome-only commands answer with a flash and echo nothing: /theme's + // repaint would wipe an echo anyway. + const surfaceOnlyView = /^\/(?:view|theme)(?:\s|$)/u.test(trimmed) if (trimmed === '') continue if (trimmed === '/exit' || trimmed === '/quit') break if (childViews.current !== undefined) { @@ -3141,7 +3276,7 @@ async function run(ctx: Context, config: Config, io: CliIo): Promise<void> { // Canned prompts share the user rail; chrome-only commands sit on the // tool rail because they are not a turn header. if (cannedPrompt) { - const pad = theme.colored ? theme.bgUser(' ') : undefined + const pad = theme.bands ? theme.bgUser(' ') : undefined const text = theme.colored ? theme.bgUser(` ${trimmed}`) : trimmed io.console.appendPrompt(pad === undefined ? [text, ''] : [text], blockRules(theme).user, true, 1, pad) } diff --git a/packages/bundle/src/inputbox.ts b/packages/bundle/src/inputbox.ts index 33ea000b..1beb609b 100644 --- a/packages/bundle/src/inputbox.ts +++ b/packages/bundle/src/inputbox.ts @@ -297,7 +297,7 @@ function paintGestures( if (from >= to) continue out += cells.slice(at, from).join('') const slice = cells.slice(from, to).join('') - out += hit.kind === 'skill' ? theme.user(slice) : hit.kind === 'image' ? theme.accent(slice) : theme.tool(slice) + out += hit.kind === 'skill' ? theme.agent(slice) : hit.kind === 'image' ? theme.accent(slice) : theme.tool(slice) at = to } return out + cells.slice(at).join('') diff --git a/packages/bundle/src/markdown.ts b/packages/bundle/src/markdown.ts index 47845b35..41803cca 100644 --- a/packages/bundle/src/markdown.ts +++ b/packages/bundle/src/markdown.ts @@ -406,7 +406,7 @@ function renderMarkdownInline(text: string, theme: Theme): string { if (parts === null) return match const [, label, target] = parts // The target is kept: a terminal cannot hide it behind a click. - return `${theme.bold(label ?? '')} ${theme.dim(`(${target ?? ''})`)}` + return `${theme.link(theme.bold(label ?? ''))} ${theme.dim(`(${target ?? ''})`)}` } if (starEm !== undefined) return emphasized(starEm.slice(1, -1)) if (underEm !== undefined) return emphasized(underEm.slice(1, -1)) @@ -632,7 +632,7 @@ function renderLine(line: string, theme: Theme, fence: FenceState): string[] { } const heading = HEADING.exec(line) if (heading !== null) { - out.push(...rows(theme.warn(renderInline(heading[2] ?? '', theme)), '', '')) + out.push(...rows(theme.heading(renderInline(heading[2] ?? '', theme)), '', '')) return out } if (RULE.test(line)) { diff --git a/packages/bundle/src/palette.ts b/packages/bundle/src/palette.ts new file mode 100644 index 00000000..33a865ae --- /dev/null +++ b/packages/bundle/src/palette.ts @@ -0,0 +1,296 @@ +/** + * The colour themes, as data: every slot a theme paints, in full RGB with + * explicit overrides where quantizing to a smaller palette would lose the + * colour's point, and the quantizers that fit a point to a terminal's depth. + * + * Modelled on Grok CLI's theme slots (`accent_user`, `bg_light`, `bg_dark`, + * `bg_thinking`, `md_code_bg`, `diff_insert_bg`, …): a theme is a table, the + * renderer asks for roles, and one resolution step turns a slot into the + * escape a given terminal can show. The brand is DeepSeek's blue, #4D6BFE. + * @module codsh-bundle/src/palette + */ + +/** A point in sRGB. */ +export type Rgb = readonly [number, number, number] + +/** How many colours the terminal can show. */ +export type Depth = 'truecolor' | '256' | '16' + +/** + * One colour, per depth. An explicit override beats quantization: a navy band + * that the 256-colour gray ramp would swallow keeps its hue by naming its + * index, and a 16-colour terminal gets the attribute that reads best there. + */ +export interface Swatch { + /** The colour itself, painted as-is on a truecolor terminal. */ + readonly rgb?: Rgb + /** A 256-colour palette index, used instead of the nearest one. */ + readonly x256?: number + /** Literal SGR parameters for a 16-colour terminal: `94`, `40`, `2`, `1`. */ + readonly x16?: string +} + +/** The theme names a person can choose. */ +export type ThemeName = 'deepseek' | 'deepseek-light' | 'terminal' + +/** A theme choice: a name, or `auto` to follow the terminal's background. */ +export type ThemeSetting = 'auto' | ThemeName + +/** Slots painted as a foreground. */ +export type ForegroundSlot = + | 'accent' | 'accent_soft' | 'fg_dim' | 'fg_muted' | 'border' + | 'success' | 'error' | 'warning' | 'tool' + | 'diff_insert_fg' | 'diff_delete_fg' + | 'syntax_keyword' | 'syntax_type' | 'syntax_fn' | 'syntax_string' | 'syntax_number' | 'syntax_comment' | 'syntax_property' + | 'logo_chevron' | 'logo_hull' | 'logo_water' + +/** Slots painted as a background. */ +export type BackgroundSlot = + | 'bg_light' | 'bg_dark' | 'bg_thinking' | 'bg_error' | 'md_code_bg' | 'bg_meta' | 'bg_hover' + | 'diff_insert_bg' | 'diff_delete_bg' + +/** Every slot a theme may fill. */ +export type Slot = ForegroundSlot | BackgroundSlot + +/** Which layer each background slot paints; everything else is a foreground. */ +const BACKGROUND_SLOTS: ReadonlySet<Slot> = new Set<BackgroundSlot>([ + 'bg_light', 'bg_dark', 'bg_thinking', 'bg_error', 'md_code_bg', 'bg_meta', 'bg_hover', + 'diff_insert_bg', 'diff_delete_bg', +]) + +/** + * Whether a slot is painted as a background. + * @param slot - the slot. + * @returns true for a `bg_*` or diff background slot. + */ +export function isBackground(slot: Slot): boolean { + return BACKGROUND_SLOTS.has(slot) +} + +/** One theme: its slots, and how it behaves where a slot is not enough. */ +export interface PaletteSpec { + readonly name: ThemeName + /** Paint only the terminal's own sixteen colours, at every depth. */ + readonly ansiOnly: boolean + /** Paint surface bands (the user prompt's fill); otherwise the prompt is bold. */ + readonly bands: boolean + /** The terminal cursor colour to set with OSC 12, as `#rrggbb`. */ + readonly cursor?: string + readonly slots: Readonly<Partial<Record<Slot, Swatch>>> +} + +/** DeepSeek's brand blue, #4D6BFE: focus, the person's rail, the cursor, the mark. */ +const BRAND: Swatch = { rgb: [77, 107, 254], x256: 63, x16: '94' } + +/** The brand's lighter tint, #7E96F5: thinking, links, headings, paths. */ +const TINT_DARK: Swatch = { rgb: [126, 150, 245], x256: 105, x16: '94' } + +/** The brand's deeper shade, #3D56D6, for text-level accents on a light background. */ +const TINT_LIGHT: Swatch = { rgb: [61, 86, 214], x256: 62, x16: '34' } + +/** Slots both DeepSeek themes share. */ +const SHARED: Readonly<Partial<Record<Slot, Swatch>>> = { + accent: BRAND, + fg_muted: { x16: '90' }, + border: { x16: '90' }, + success: { x16: '32' }, + error: { x16: '31' }, + syntax_comment: { x16: '2' }, + logo_water: { ...BRAND, x16: '34' }, +} + +/** DeepSeek on a dark background. */ +export const DEEPSEEK: PaletteSpec = { + name: 'deepseek', + ansiOnly: false, + bands: true, + cursor: '#4d6bfe', + slots: { + ...SHARED, + accent_soft: TINT_DARK, + fg_dim: { x256: 245, x16: '2' }, + warning: { x256: 172, x16: '93' }, + tool: { x256: 172, x16: '33' }, + bg_light: { rgb: [21, 26, 48], x256: 17, x16: '40' }, + bg_dark: { rgb: [14, 18, 24], x256: 235, x16: '40' }, + // 237 on purpose: the nearest index is 235, which is the tool fill's. + bg_thinking: { rgb: [26, 29, 46], x256: 237, x16: '40' }, + bg_error: { rgb: [45, 15, 25], x256: 52, x16: '41' }, + md_code_bg: { rgb: [15, 18, 24], x256: 235, x16: '40' }, + bg_meta: { rgb: [18, 20, 26], x256: 236, x16: '40' }, + bg_hover: { x256: 236 }, + diff_insert_bg: { rgb: [10, 38, 30], x256: 22, x16: '42' }, + diff_insert_fg: { rgb: [80, 200, 140], x256: 120, x16: '30' }, + diff_delete_bg: { rgb: [45, 15, 25], x256: 52, x16: '41' }, + diff_delete_fg: { rgb: [240, 100, 110], x256: 203, x16: '37' }, + syntax_keyword: { rgb: [197, 134, 192], x256: 176, x16: '35' }, + syntax_type: { rgb: [78, 201, 176], x256: 73, x16: '36' }, + syntax_fn: { rgb: [220, 220, 170], x256: 186, x16: '33' }, + syntax_string: { rgb: [206, 145, 120], x256: 173, x16: '32' }, + syntax_number: { rgb: [181, 206, 168], x256: 151, x16: '36' }, + syntax_property: { rgb: [156, 220, 254], x256: 117, x16: '34' }, + logo_chevron: TINT_DARK, + logo_hull: { rgb: [238, 241, 251], x256: 255, x16: '97' }, + }, +} + +/** DeepSeek on a light background: the same brand, deeper text accents. */ +export const DEEPSEEK_LIGHT: PaletteSpec = { + name: 'deepseek-light', + ansiOnly: false, + bands: true, + cursor: '#4d6bfe', + slots: { + ...SHARED, + accent_soft: TINT_LIGHT, + fg_dim: { x256: 242, x16: '2' }, + warning: { x256: 130, x16: '93' }, + tool: { x256: 130, x16: '33' }, + bg_light: { rgb: [238, 241, 251], x256: 189, x16: '47' }, + bg_dark: { rgb: [243, 245, 248], x256: 255, x16: '47' }, + bg_thinking: { rgb: [243, 245, 253], x256: 255, x16: '47' }, + bg_error: { rgb: [254, 226, 226], x256: 224, x16: '41' }, + md_code_bg: { rgb: [240, 242, 246], x256: 254, x16: '47' }, + bg_meta: { rgb: [244, 244, 246], x256: 255, x16: '47' }, + bg_hover: { x256: 253 }, + diff_insert_bg: { rgb: [236, 253, 245], x256: 194, x16: '42' }, + diff_insert_fg: { rgb: [22, 101, 52], x256: 28, x16: '30' }, + diff_delete_bg: { rgb: [254, 242, 242], x256: 224, x16: '41' }, + diff_delete_fg: { rgb: [153, 27, 27], x256: 160, x16: '37' }, + syntax_keyword: { rgb: [175, 0, 219], x256: 176, x16: '35' }, + syntax_type: { rgb: [38, 127, 153], x256: 73, x16: '36' }, + syntax_fn: { rgb: [121, 94, 38], x256: 186, x16: '33' }, + syntax_string: { rgb: [163, 21, 21], x256: 173, x16: '32' }, + syntax_number: { rgb: [9, 134, 88], x256: 151, x16: '36' }, + syntax_property: { rgb: [0, 16, 128], x256: 117, x16: '34' }, + logo_chevron: TINT_LIGHT, + logo_hull: { rgb: [21, 26, 48], x256: 17, x16: '30' }, + }, +} + +/** + * The terminal's own palette: no surfaces of its own, the sixteen ANSI colours + * a profile tunes, and bright black for decoration — so a translucent or + * image-backed window shows through, and every depth renders the same. + */ +export const TERMINAL: PaletteSpec = { + name: 'terminal', + ansiOnly: true, + bands: false, + slots: { + accent: { x16: '34' }, + accent_soft: { x16: '36' }, + fg_dim: { x16: '90' }, + fg_muted: { x16: '90' }, + border: { x16: '90' }, + success: { x16: '32' }, + error: { x16: '31' }, + warning: { x16: '93' }, + tool: { x16: '33' }, + // No band: the person's message is bold instead. + bg_light: { x16: '1' }, + diff_insert_fg: { x16: '32' }, + diff_delete_fg: { x16: '31' }, + syntax_keyword: { x16: '35' }, + syntax_type: { x16: '36' }, + syntax_fn: { x16: '33' }, + syntax_string: { x16: '32' }, + syntax_number: { x16: '36' }, + syntax_comment: { x16: '90' }, + syntax_property: { x16: '34' }, + logo_chevron: { x16: '36' }, + logo_hull: { x16: '39' }, + logo_water: { x16: '34' }, + }, +} + +/** Every theme, by name. */ +export const PALETTES: Readonly<Record<ThemeName, PaletteSpec>> = { + 'deepseek': DEEPSEEK, + 'deepseek-light': DEEPSEEK_LIGHT, + 'terminal': TERMINAL, +} + +/** + * The SGR parameters one swatch paints at a depth, on one layer. + * + * Truecolor takes the point itself; 256 colours take the named index or the + * nearest one; 16 colours take the named parameters or the nearest ANSI + * colour. A swatch that names only a 256 index (a hover fill) keeps it at + * every depth — that is how it was always painted. + * @param swatch - the colour, or undefined for "not painted". + * @param layer - foreground or background. + * @param depth - the terminal's depth. + * @returns the parameters without `ESC [` and `m`, or `''`. + */ +export function params(swatch: Swatch | undefined, layer: 'fg' | 'bg', depth: Depth): string { + if (swatch === undefined) return '' + const base = layer === 'fg' ? 38 : 48 + const { rgb, x256, x16 } = swatch + const indexed = (index: number): string => `${base};5;${index}` + if (depth === 'truecolor') { + if (rgb !== undefined) return `${base};2;${rgb[0]};${rgb[1]};${rgb[2]}` + if (x256 !== undefined) return indexed(x256) + return x16 ?? '' + } + if (depth === '256') { + if (x256 !== undefined) return indexed(x256) + if (rgb !== undefined) return indexed(nearest256(rgb[0], rgb[1], rgb[2])) + return x16 ?? '' + } + if (x16 !== undefined) return x16 + if (rgb !== undefined) { + const code = nearestAnsi(rgb[0], rgb[1], rgb[2]) + return layer === 'fg' ? code : String(Number(code) + 10) + } + return x256 === undefined ? '' : indexed(x256) +} + +/** The sixteen ANSI colours as xterm paints them, with their SGR foreground codes. */ +export const ANSI_PALETTE: readonly [number, number, number, string][] = [ + [0, 0, 0, '30'], [205, 0, 0, '31'], [0, 205, 0, '32'], [205, 205, 0, '33'], + [0, 0, 238, '34'], [205, 0, 205, '35'], [0, 205, 205, '36'], [229, 229, 229, '37'], + [127, 127, 127, '90'], [255, 0, 0, '91'], [0, 255, 0, '92'], [255, 255, 0, '93'], + [92, 92, 255, '94'], [255, 0, 255, '95'], [0, 255, 255, '96'], [255, 255, 255, '97'], +] + +/** Squared distance between two sRGB points; ordering is all that is needed. */ +const apart = (r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number => + (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 + +/** + * The SGR foreground code of the ANSI colour closest to an sRGB point. + * @param red - 0..255. + * @param green - 0..255. + * @param blue - 0..255. + * @returns a code such as `94`. + */ +export function nearestAnsi(red: number, green: number, blue: number): string { + let best = ANSI_PALETTE[0] ?? [0, 0, 0, '37'] + for (const candidate of ANSI_PALETTE) { + if (apart(red, green, blue, candidate[0], candidate[1], candidate[2]) < apart(red, green, blue, best[0], best[1], best[2])) best = candidate + } + return best[3] +} + +/** Levels of the 256-colour palette's 6×6×6 cube. */ +export const CUBE: readonly number[] = [0, 95, 135, 175, 215, 255] + +/** + * The 256-colour index closest to an sRGB point: the cube, or the gray ramp + * when that is nearer. + * @param red - 0..255. + * @param green - 0..255. + * @param blue - 0..255. + * @returns an index in 16..255. + */ +export function nearest256(red: number, green: number, blue: number): number { + const level = (value: number): number => + CUBE.reduce((best, candidate, index) => Math.abs(candidate - value) < Math.abs((CUBE[best] ?? 0) - value) ? index : best, 0) + const [ri, gi, bi] = [level(red), level(green), level(blue)] + const cubeApart = apart(red, green, blue, CUBE[ri] ?? 0, CUBE[gi] ?? 0, CUBE[bi] ?? 0) + // The ramp runs 232..255 at 8, 18, … 238. + const step = Math.min(23, Math.max(0, Math.round((Math.round((red + green + blue) / 3) - 8) / 10))) + const gray = 8 + 10 * step + return apart(red, green, blue, gray, gray, gray) < cubeApart ? 232 + step : 16 + 36 * ri + 6 * gi + bi +} diff --git a/packages/bundle/src/prefs.ts b/packages/bundle/src/prefs.ts new file mode 100644 index 00000000..cdae897a --- /dev/null +++ b/packages/bundle/src/prefs.ts @@ -0,0 +1,45 @@ +/** + * The surface's own preferences file: one JSON object under the dsh home that + * `/ui` and `/theme` each keep a key in. + * + * Every write merges into what is there, so saving one preference never + * erases another, and lands by rename, so a crash mid-write never leaves half + * a file for the next boot to read. + * @module codsh-bundle/src/prefs + */ + +import { readFile, rename, writeFile } from 'node:fs/promises' + +/** Filename under the dsh home, beside `code-cli-history.json`. */ +export const UI_PREFS_FILE = 'code-cli-ui.json' + +/** What a prefs file holds: whatever keys any version wrote. */ +export type Prefs = Record<string, unknown> + +/** + * Read the prefs file. + * @param path - the JSON file. + * @returns its object, or an empty one when it is missing, unreadable, or not an object. + */ +export async function readPrefs(path: string): Promise<Prefs> { + try { + const parsed: unknown = JSON.parse(await readFile(path, 'utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + return parsed as Prefs + } catch { + return {} + } +} + +/** + * Merge keys into the prefs file, keeping every key this write does not name + * — including ones a newer version wrote. + * @param path - the JSON file. + * @param patch - the keys to set. + */ +export async function mergePrefs(path: string, patch: Prefs): Promise<void> { + const next = { ...await readPrefs(path), ...patch } + const staging = `${path}.${process.pid}.tmp` + await writeFile(staging, `${JSON.stringify(next)}\n`) + await rename(staging, path) +} diff --git a/packages/bundle/src/prompt.ts b/packages/bundle/src/prompt.ts index f9ba83d0..08c54a23 100644 --- a/packages/bundle/src/prompt.ts +++ b/packages/bundle/src/prompt.ts @@ -431,6 +431,15 @@ export class Prompt { this.render() } + /** + * Repaint the chrome after the theme changed. Everything here — the box, + * a selector, a modal, the status row — is composed per render from the + * live theme, so a render is all it takes. + */ + restyle(): void { + this.render() + } + setHint(text: string | undefined): void { if (text === this.hint) return this.hint = text @@ -637,15 +646,25 @@ export class Prompt { * fallback for pipes, where the selection keys cannot arrive. * @param spec - the question and its options. * @param signal - cancels the selection, which an aborted tool call does. + * @param preview - hears every row the marker lands on. + * @param settled - hears the outcome before the frame that closes the + * selection is painted, so a caller whose preview changed the chrome can + * put it right without a frame of the preview showing after the choice. * @returns how the person decided. */ - select(spec: SelectSpec, signal?: AbortSignal, preview?: (index: number) => void): Promise<SelectOutcome> { + select( + spec: SelectSpec, + signal?: AbortSignal, + preview?: (index: number) => void, + settled?: (outcome: SelectOutcome) => void, + ): Promise<SelectOutcome> { if (this.console.finished || signal?.aborted === true) { return Promise.resolve({ kind: 'cancelled' }) } return new Promise<SelectOutcome>((resolve) => { const settle = (outcome: SelectOutcome): void => { this.select_ = undefined + settled?.(outcome) resolve(outcome) this.render() } diff --git a/packages/bundle/src/screen.ts b/packages/bundle/src/screen.ts index ae6f743a..b7368b92 100644 --- a/packages/bundle/src/screen.ts +++ b/packages/bundle/src/screen.ts @@ -16,15 +16,8 @@ * @module codsh-bundle/src/screen */ -import { - displayWidth, - oneRow, - truncate, - BG_USER_DARK_TRUECOLOR, - BG_USER_DARK_256, - BG_USER_LIGHT_TRUECOLOR, - BG_USER_LIGHT_256, -} from './theme.ts' +import { createTheme, displayWidth, oneRow, truncate } from './theme.ts' +import type { Theme } from './theme.ts' import { computeStickyLayout } from './sticky.ts' import type { TerminalGraphic } from './terminal-graphics.ts' import { computeTimeline } from './timeline.ts' @@ -106,20 +99,23 @@ const INVERSE = '\u001B[7m' /** End reverse video only, leaving any other attributes alone. */ const INVERSE_OFF = '\u001B[27m' -/** Dark-background hover fill, a slight lift off the default black. */ -const FILL_DARK = '\u001B[48;5;236m' - -/** Light-background hover fill, a slight drop off the default white. */ -const FILL_LIGHT = '\u001B[48;5;253m' - /** Restore the terminal's default background, leaving other attributes. */ const FILL_OFF = '\u001B[49m' /** Dim the transcript around a centered preview so the picture is what reads. */ const MASK_DIM = '\u001B[2m' -/** Muted color for dividers and borders. */ -const MUTED = '\u001B[90m' +/** End bold and dim only, for a mask with no fill of its own to switch off. */ +const INTENSITY_OFF = '\u001B[22m' + +/** + * Colour the terminal cursor (OSC 12), which marks the session the way Grok + * CLI does; BEL-terminated like every other OSC this surface sends. + */ +const cursorColor = (color: string): string => `\u001B]12;${color}\u0007` + +/** Give the cursor its own colour back (OSC 112). */ +const CURSOR_COLOR_RESET = '\u001B]112\u0007' /** A full SGR reset, which every styled span this surface prints ends with. */ const RESET = '\u001B[0m' @@ -133,7 +129,7 @@ const RESET = '\u001B[0m' * the block reads as a panel, the way opencode fills `backgroundElement`. * @param row - the styled row. * @param columns - display columns the panel should occupy. - * @param light - whether the terminal background is light. + * @param bg - the fill's escape, or `''` for a theme that paints none. * @returns the row, filled end to end. */ /** @@ -143,26 +139,26 @@ const RESET = '\u001B[0m' * steps back — dimmed text on the hover-panel fill — so the card is what * the eye lands on. Chrome under the overlay stays as it is. */ -function mask(row: string, columns: number, light: boolean): string { - const bg = light ? FILL_LIGHT : FILL_DARK +function mask(row: string, columns: number, bg: string): string { const noBg = row.replaceAll(/\u001B\[(?:48;[0-9;]*|49)m/gu, '') const pad = Math.max(0, columns - displayWidth(noBg)) const padded = `${noBg}${' '.repeat(pad)}` - return `${bg}${MASK_DIM}${padded.replaceAll(RESET, `${RESET}${bg}${MASK_DIM}`)}${FILL_OFF}` + return `${bg}${MASK_DIM}${padded.replaceAll(RESET, `${RESET}${bg}${MASK_DIM}`)}${bg === '' ? INTENSITY_OFF : FILL_OFF}` } /** * Dim the empty columns beside a centered card, leaving the card itself clear. */ -function maskSides(row: string, columns: number, light: boolean): string { +function maskSides(row: string, columns: number, bg: string): string { const clipped = truncate(row, columns) + // Blank columns dimmed on no fill are still blank. + if (bg === '') return clipped const plain = clipped.replaceAll(STYLES, '') let leading = 0 while (leading < plain.length && plain[leading] === ' ') leading += 1 let trailing = 0 while (trailing < plain.length - leading && plain[plain.length - 1 - trailing] === ' ') trailing += 1 if (leading === 0 && trailing === 0) return clipped - const bg = light ? FILL_LIGHT : FILL_DARK const left = leading > 0 ? `${bg}${MASK_DIM}${' '.repeat(leading)}${FILL_OFF}` : '' const rightPad = Math.max(0, columns - displayWidth(clipped) + trailing) const right = rightPad > 0 ? `${bg}${MASK_DIM}${' '.repeat(rightPad)}${FILL_OFF}` : '' @@ -170,8 +166,9 @@ function maskSides(row: string, columns: number, light: boolean): string { return `${left}${body}${right}` } -function fill(row: string, columns: number, light: boolean): string { - const bg = light ? FILL_LIGHT : FILL_DARK +function fill(row: string, columns: number, bg: string): string { + // A theme that paints no surfaces leaves the row as it is. + if (bg === '') return row // Strip any existing background escape sequences so the hover fill is completely // uniform across both text and trailing padding, rather than creating two-tone cutouts. const noBg = row.replaceAll(/\u001B\[(?:48;[0-9;]*|49)m/gu, '') @@ -448,8 +445,14 @@ export class Screen { private hovered: Fold | undefined /** Prompt whose truncated sticky copy is expanded in the floating header. */ private stickyOpen: TurnPrompt | undefined - /** Whether OSC 11 named a light background; the hover fill picks a shade. */ - private light = false + /** + * The palette this surface paints its own fills with — hover, mask, the + * sticky header, the divider — shared with the renderer once adopted, so a + * theme switch reaches them without a restart. + */ + private theme: Theme + /** The cursor colour OSC 12 last set, so leaving restores only what was changed. */ + private cursorShown: string | undefined /** * Physical row ranges the blocks occupy, or undefined when they need * measuring again. @@ -490,10 +493,10 @@ export class Screen { /** Opens long text in the reader when its block is clicked. */ private pagerHandler: ((text: string) => void) | undefined - private readonly truecolor: boolean - constructor(private readonly host: ScreenHost, env: Record<string, string | undefined> = {}) { - this.truecolor = env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' + // Its own until the renderer's is adopted; NO_COLOR yields a theme that + // paints nothing, so no fill reaches a person who asked for none. + this.theme = createTheme(true, env) } /** @@ -559,13 +562,41 @@ export class Screen { } /** - * Adopt the light- or dark-background hover fill. + * Adopt the light- or dark-background palette for the fills this surface + * paints itself. * @param light - true when OSC 11 named a light color. */ setLight(light: boolean): void { - if (this.light === light) return - this.light = light - if (this.hovered !== undefined || this.frameLayout().sticky !== undefined) this.render() + if (this.theme.setLight(light)) this.restyle() + } + + /** + * Paint with the renderer's theme from now on. + * @param theme - the theme every other part of the surface paints with. + */ + useTheme(theme: Theme): void { + this.theme = theme + this.restyle() + } + + /** + * Repaint the whole frame after the theme changed: the fills are composed + * per frame, so nothing stored needs rewriting, and the cursor takes the + * new accent. + */ + restyle(): void { + if (this.active) this.syncCursorColor() + this.painted = [] + this.paintedTimeline = [] + this.render() + } + + /** Point the cursor colour at the theme's, or give it back when the theme sets none. */ + private syncCursorColor(): void { + const wanted = this.theme.cursor + if (wanted === this.cursorShown) return + this.host.write(wanted === undefined ? CURSOR_COLOR_RESET : cursorColor(wanted)) + this.cursorShown = wanted } /** Physical rows scrolled up out of view; zero means the tail is showing. */ @@ -689,7 +720,9 @@ export class Screen { enter(): void { if (this.active) return this.active = true - this.host.write(`${ENTER_ALT}${ENABLE_MOUSE}${ENABLE_KITTY_KEYS}${ENABLE_FOCUS}${QUERY_BACKGROUND}${HIDE_CURSOR}`) + const cursor = this.theme.cursor === undefined ? '' : cursorColor(this.theme.cursor) + this.cursorShown = this.theme.cursor + this.host.write(`${ENTER_ALT}${ENABLE_MOUSE}${ENABLE_KITTY_KEYS}${ENABLE_FOCUS}${QUERY_BACKGROUND}${cursor}${HIDE_CURSOR}`) this.painted = [] this.paintedTimeline = [] this.render() @@ -708,7 +741,9 @@ export class Screen { // leaving the alternate screen with a placement still live can drop the // picture onto the shell the session hands the terminal back to. const graphic = this.paintedGraphic?.clear ?? '' - this.host.write(`${graphic}${DISABLE_FOCUS}${DISABLE_KITTY_KEYS}${DISABLE_MOUSE}${SHOW_CURSOR}${LEAVE_ALT}`) + const cursor = this.cursorShown === undefined ? '' : CURSOR_COLOR_RESET + this.cursorShown = undefined + this.host.write(`${graphic}${DISABLE_FOCUS}${DISABLE_KITTY_KEYS}${DISABLE_MOUSE}${cursor}${SHOW_CURSOR}${LEAVE_ALT}`) this.painted = [] this.paintedTimeline = [] this.paintedGraphic = undefined @@ -2683,15 +2718,15 @@ export class Screen { * completely uniform across both text and trailing padding. * @param row - the styled row. * @param columns - display columns the panel should occupy. - * @returns the row, filled end to end with the deep plum / lavender background. + * @returns the row, filled end to end with the person's band; only padded + * under a theme that paints no bands. */ private fillSticky(row: string, columns: number): string { - const bg = this.light - ? (this.truecolor ? BG_USER_LIGHT_TRUECOLOR : BG_USER_LIGHT_256) - : (this.truecolor ? BG_USER_DARK_TRUECOLOR : BG_USER_DARK_256) + const bg = this.theme.bands ? this.theme.sgr('bg_light') : '' const noBg = row.replaceAll(/\u001B\[(?:48;[0-9;]*|49)m/gu, '') const pad = Math.max(0, columns - displayWidth(noBg)) const padded = `${noBg}${' '.repeat(pad)}` + if (bg === '') return padded return `${bg}${padded.replaceAll(RESET, `${RESET}${bg}`)}${FILL_OFF}` } @@ -2784,7 +2819,8 @@ export class Screen { const header = source.slice(from, from + sticky.renderHeight) const width = this.contentColumns() const filledHeader = header.map(row => this.fillSticky(truncate(row, width), width)) - const divider = `${MUTED}${'─'.repeat(width)}${RESET}` + const rule = this.theme.sgr('border') + const divider = `${rule}${'─'.repeat(width)}${rule === '' ? '' : RESET}` // Pinned, the header is a floating panel: a padding row of its own fill // above and below the prompt, then the divider that hands the screen // back to the transcript. A viewport too short for all three gives them @@ -2816,7 +2852,7 @@ export class Screen { if (rawRow.replaceAll(STYLES, '').trim() === '' && !/\u001B\[48;[0-9;]*m/.test(rawRow)) continue const vpIndex = (sticky !== undefined ? (sticky.state === 'pinned' ? sticky.reservedRows : sticky.renderHeight) : 0) + index if (vpIndex < viewport.length) { - viewport[vpIndex] = fill(rawRow, contentWidth, this.light) + viewport[vpIndex] = fill(rawRow, contentWidth, this.theme.sgr('bg_hover')) } } } @@ -2840,7 +2876,7 @@ export class Screen { if (this.overlayCentered) { for (let at = 0; at < viewport.length; at += 1) { if (at < start || at >= start + this.overlay.length) { - viewport[at] = mask(viewport[at] ?? '', width, this.light) + viewport[at] = mask(viewport[at] ?? '', width, this.theme.sgr('bg_hover')) } } } @@ -2848,8 +2884,8 @@ export class Screen { const at = start + index if (at < viewport.length) { viewport[at] = this.overlayCentered - ? maskSides(row, width, this.light) - : fill(truncate(row, width), width, this.light) + ? maskSides(row, width, this.theme.sgr('bg_hover')) + : fill(truncate(row, width), width, this.theme.sgr('bg_hover')) } }) } @@ -2882,7 +2918,7 @@ export class Screen { if (preview !== undefined) { const width = this.host.columns() - preview.column preview.rows.forEach((row, index) => { - out += `\u001B[${preview.row + index + 1};${preview.column}H${fill(truncate(row, width), width, this.light)}` + out += `\u001B[${preview.row + index + 1};${preview.column}H${fill(truncate(row, width), width, this.theme.sgr('bg_hover'))}` }) } const rail = Array.from({ length: height }, () => ' ') diff --git a/packages/bundle/src/theme-picker.ts b/packages/bundle/src/theme-picker.ts new file mode 100644 index 00000000..4ccd3e48 --- /dev/null +++ b/packages/bundle/src/theme-picker.ts @@ -0,0 +1,152 @@ +/** + * The `/theme` picker and the history repaint behind it. + * + * Moving through the list previews each theme live, the way Grok CLI's + * picker does: the chrome repaints at once, and the conversation above it is + * repainted in the new palette once the marker settles. Enter keeps the + * choice; Esc puts back the one in force before the picker opened. + * @module codsh-bundle/src/theme-picker + */ + +import type { ThemeSetting } from './palette.ts' +import type { SelectOutcome, SelectSpec } from './selector.ts' +import { THEME_CHOICES } from './theme-setting.ts' + +/** How long the marker must rest before the history is repainted for a preview. */ +export const PREVIEW_REPAINT_MS = 150 + +/** A history repaint slower than this is skipped while previewing; the chrome still follows. */ +export const SLOW_REPAINT_MS = 250 + +/** What {@link HistoryRepaint} needs from the surface. */ +export interface HistoryRepaintHost { + /** Whether the painted history is in another palette than the live one. */ + stale(): boolean + /** Whether repainting now would disturb work in flight (a running turn, a Child view). */ + busy(): boolean + /** + * Repaint the history in the live palette. + * @returns how long it took, in milliseconds. + */ + run(): number + setTimer?: (callback: () => void, ms: number) => unknown + clearTimer?: (handle: unknown) => void +} + +/** + * When the conversation above the prompt is repainted after a theme change. + * + * Repainting replays the whole session, so it waits for a moment the person + * is not in the middle of: never under a running turn or inside a Child view + * — those {@link flush} later — and, while previewing, only once the marker + * rests and only when the last repaint was quick. + */ +export class HistoryRepaint { + private pending = false + private timer: unknown + private lastMs = 0 + private readonly setTimer: (callback: () => void, ms: number) => unknown + private readonly clearTimer: (handle: unknown) => void + + constructor(private readonly host: HistoryRepaintHost) { + this.setTimer = host.setTimer ?? ((callback, ms) => setTimeout(callback, ms)) + this.clearTimer = host.clearTimer ?? (handle => { clearTimeout(handle as ReturnType<typeof setTimeout>) }) + } + + /** Repaint now if the history is stale and nothing is in flight; otherwise at the next {@link flush}. */ + request(): void { + this.cancelTimer() + if (!this.host.stale()) { + this.pending = false + return + } + if (this.host.busy()) { + this.pending = true + return + } + this.pending = false + this.lastMs = this.host.run() + } + + /** A preview's repaint: debounced, and skipped for a session too long to repaint per keystroke. */ + soon(): void { + this.cancelTimer() + if (this.lastMs > SLOW_REPAINT_MS) { + this.pending = true + return + } + this.timer = this.setTimer(() => { + this.timer = undefined + this.request() + }, PREVIEW_REPAINT_MS) + } + + /** Run a repaint deferred by a running turn or a Child view, now that it is safe. */ + flush(): void { + if (this.pending) this.request() + } + + /** Drop a scheduled preview repaint. */ + cancel(): void { + this.cancelTimer() + this.pending = false + } + + private cancelTimer(): void { + if (this.timer === undefined) return + this.clearTimer(this.timer) + this.timer = undefined + } +} + +/** What {@link pickTheme} drives. */ +export interface ThemePickerHost { + /** + * Offer a selection; `preview` hears every row the marker lands on, and + * `settled` the outcome before the closing frame is painted. + */ + select( + spec: SelectSpec, + signal: AbortSignal | undefined, + preview: (index: number) => void, + settled: (outcome: SelectOutcome) => void, + ): Promise<SelectOutcome> + /** The choice in force when the picker opens. */ + current(): ThemeSetting + /** + * Put a choice in force for the chrome. + * @returns whether the palette changed. + */ + apply(setting: ThemeSetting): boolean + readonly history: HistoryRepaint +} + +/** + * Let the person pick a theme, previewing each as the marker moves. + * @param host - the selection and the theme it switches. + * @param signal - cancels the picker, reverting the preview. + * @returns the chosen theme, or undefined when the picker was dismissed. + */ +export async function pickTheme(host: ThemePickerHost, signal?: AbortSignal): Promise<ThemeSetting | undefined> { + const original = host.current() + const choiceOf = (outcome: SelectOutcome): ThemeSetting | undefined => + outcome.kind === 'chosen' ? THEME_CHOICES[outcome.indices[0] ?? -1]?.name : undefined + const outcome = await host.select({ + title: 'Theme', + options: THEME_CHOICES.map(choice => ({ + label: choice.name, + detail: choice.name === original ? `${choice.detail} · current` : choice.detail, + })), + prior: { selected: original }, + }, signal, (index) => { + const choice = THEME_CHOICES[index] + if (choice !== undefined && host.apply(choice.name)) host.history.soon() + }, (settled) => { + // Before the closing frame: the box comes back in the theme that stays. + host.history.cancel() + host.apply(choiceOf(settled) ?? original) + }) + const chosen = choiceOf(outcome) + host.history.request() + return chosen +} diff --git a/packages/bundle/src/theme-setting.ts b/packages/bundle/src/theme-setting.ts new file mode 100644 index 00000000..c91e8b68 --- /dev/null +++ b/packages/bundle/src/theme-setting.ts @@ -0,0 +1,120 @@ +/** + * The `/theme` choice: which names and aliases it accepts, where the one in + * force comes from, and how it is reported and kept. + * + * Names are case-insensitive and forgiving the way Grok CLI's are — `dark`, + * `light`, and `system` mean what a person would expect — and `CODSH_THEME` + * picks a theme for one launch without touching the saved one, the way + * `GROK_THEME` does. + * @module codsh-bundle/src/theme-setting + */ + +import type { ThemeSetting } from './palette.ts' +import { mergePrefs, readPrefs } from './prefs.ts' +import type { Theme } from './theme.ts' + +/** One row of the theme catalog. */ +export interface ThemeChoice { + readonly name: ThemeSetting + readonly detail: string +} + +/** Every choice, in the order the picker lists them. */ +export const THEME_CHOICES: readonly ThemeChoice[] = [ + { name: 'auto', detail: 'DeepSeek blue, light or dark to match the terminal' }, + { name: 'deepseek', detail: 'DeepSeek blue on a dark background' }, + { name: 'deepseek-light', detail: 'DeepSeek blue on a light background' }, + { name: 'terminal', detail: 'your terminal\'s own sixteen colours, no backgrounds' }, +] + +/** The environment variable that picks a theme for one launch. */ +export const THEME_ENV = 'CODSH_THEME' + +/** Other names each choice answers to. */ +const ALIASES: Readonly<Record<string, ThemeSetting>> = { + 'system': 'auto', + 'default': 'auto', + 'dark': 'deepseek', + 'deepseek-dark': 'deepseek', + 'ds': 'deepseek', + 'light': 'deepseek-light', + 'ds-light': 'deepseek-light', + 'native': 'terminal', + 'ansi': 'terminal', + 'basic': 'terminal', +} + +/** + * Read a theme name the way a person types it. + * @param raw - a name or alias, any case, possibly padded. + * @returns the choice, or undefined when it names none. + */ +export function parseThemeSetting(raw: string): ThemeSetting | undefined { + const name = raw.trim().toLowerCase() + const exact = THEME_CHOICES.find(choice => choice.name === name) + return exact?.name ?? ALIASES[name] +} + +/** Where the theme in force at startup came from. */ +export interface StartupTheme { + readonly setting: ThemeSetting + /** Whether `CODSH_THEME` decided it. */ + readonly fromEnv: boolean + /** Why an environment value was ignored, when it was. */ + readonly warning?: string +} + +/** + * The theme a session starts with: `CODSH_THEME` when it names one, else the + * saved choice, else `auto`. + * @param env - the launch environment. + * @param saved - the prefs file's `theme` value, whatever it holds. + * @returns the choice and its provenance. + */ +export function startupThemeSetting(env: Record<string, string | undefined>, saved: unknown): StartupTheme { + const fromSaved = typeof saved === 'string' ? parseThemeSetting(saved) : undefined + const raw = env[THEME_ENV] + if (raw !== undefined && raw.trim() !== '') { + const chosen = parseThemeSetting(raw) + if (chosen !== undefined) return { setting: chosen, fromEnv: true } + return { setting: fromSaved ?? 'auto', fromEnv: false, warning: `${THEME_ENV}="${raw}" names no theme — ${choiceList()}` } + } + return { setting: fromSaved ?? 'auto', fromEnv: false } +} + +/** + * The choices as one comma-separated list, for an error. + * @returns `auto, deepseek, deepseek-light, terminal`. + */ +export function choiceList(): string { + return THEME_CHOICES.map(choice => choice.name).join(', ') +} + +/** + * Report the theme the way `/theme` prints it. + * @param theme - the live theme. + * @returns e.g. `theme · auto → deepseek-light`, or `theme · terminal`. + */ +export function themeReport(theme: Theme): string { + if (!theme.colored) return `theme · ${theme.setting} · colour is off (NO_COLOR or not a terminal)` + const painted = theme.resolved ?? theme.setting + return theme.setting === painted ? `theme · ${painted}` : `theme · ${theme.setting} → ${painted}` +} + +/** + * Read the saved theme from the prefs file. + * @param path - the prefs file. + * @returns the raw saved value, for {@link startupThemeSetting}. + */ +export async function loadThemeSetting(path: string): Promise<unknown> { + return (await readPrefs(path)).theme +} + +/** + * Keep a theme choice for the next session, leaving the file's other keys. + * @param path - the prefs file. + * @param setting - the choice. + */ +export async function saveThemeSetting(path: string, setting: ThemeSetting): Promise<void> { + await mergePrefs(path, { theme: setting }) +} diff --git a/packages/bundle/src/theme.ts b/packages/bundle/src/theme.ts index f4c7ed5b..e5db640a 100644 --- a/packages/bundle/src/theme.ts +++ b/packages/bundle/src/theme.ts @@ -5,14 +5,10 @@ */ import stringWidth from 'string-width' +import { DEEPSEEK, DEEPSEEK_LIGHT, PALETTES, isBackground, nearest256, nearestAnsi, params } from './palette.ts' +import type { Depth, PaletteSpec, Slot, ThemeName, ThemeSetting } from './palette.ts' -/** Background color for user messages and sticky headers (deep plum / eggplant in dark mode). */ -export const BG_USER_DARK_TRUECOLOR = '\u001B[48;2;30;19;38m' -export const BG_USER_DARK_256 = '\u001B[48;5;53m' - -/** Background color for user messages and sticky headers (soft lavender in light mode). */ -export const BG_USER_LIGHT_TRUECOLOR = '\u001B[48;2;243;234;246m' -export const BG_USER_LIGHT_256 = '\u001B[48;5;225m' +export type { Slot, ThemeName, ThemeSetting } from './palette.ts' /** SGR codes applied by {@link Theme}, by role. */ const SGR = { @@ -22,20 +18,20 @@ const SGR = { italic: '\u001B[3m', underline: '\u001B[4m', strike: '\u001B[9m', - red: '\u001B[31m', - green: '\u001B[32m', - yellow: '\u001B[33m', - blue: '\u001B[34m', - magenta: '\u001B[35m', - cyan: '\u001B[36m', - brightBlack: '\u001B[90m', - brightYellow: '\u001B[93m', } as const /** Style roles the renderer asks for, resolved to SGR codes by {@link createTheme}. */ export interface Theme { /** Whether this theme emits SGR sequences at all. */ readonly colored: boolean + /** The theme choice in force: a name, or `auto`. */ + readonly setting: ThemeSetting + /** The palette painting now, or undefined when nothing is painted. */ + readonly resolved: ThemeName | undefined + /** Whether surfaces get bands (the person's message fill); otherwise it is bold. */ + readonly bands: boolean + /** The cursor colour this theme sets with OSC 12, as `#rrggbb`, if any. */ + readonly cursor: string | undefined dim(text: string): string bold(text: string): string /** Strikethrough text. */ @@ -52,9 +48,9 @@ export interface Theme { color(spec: string): ((text: string) => string) | undefined /** Secondary chrome text (status model/cwd, legend, separators). */ muted(text: string): string - /** Focus and selection only: input frame default, marked selector rows. */ + /** Brand and focus: input frame, the person's rail, marked selector rows. */ accent(text: string): string - /** Agent / user identity colour. */ + /** Agent identity: thinking, skills, the model's side of the conversation. */ agent(text: string): string /** Failures and alarms. */ err(text: string): string @@ -66,16 +62,36 @@ export interface Theme { tool(text: string): string /** File paths and locations. */ path(text: string): string + /** A heading in an answer. */ + heading(text: string): string + /** A link's label in an answer. */ + link(text: string): string /** Alias for {@link Theme.err}. */ error(text: string): string /** Alias for {@link Theme.ok}. */ success(text: string): string /** Alias for {@link Theme.warn}. */ pending(text: string): string - /** Alias for {@link Theme.agent}. */ + /** The person's side of the conversation: the brand, like {@link Theme.accent}. */ user(text: string): string - /** Adopt the light- or dark-background palette. */ - setLight(light: boolean): void + /** + * Adopt the light- or dark-background palette. Only `auto` follows it; a + * theme chosen by name keeps its own polarity. + * @returns whether the painting palette changed. + */ + setLight(light: boolean): boolean + /** + * Switch theme in place: every role already handed out paints the new + * palette from its next call. + * @returns whether the painting palette changed. + */ + setTheme(setting: ThemeSetting): boolean + /** + * The raw escape one slot paints, for a renderer that composes its own + * rows (a hover fill, the sticky header, the mark). + * @returns the SGR sequence, or `''` when the slot is not painted. + */ + sgr(slot: Slot): string /** Roles used inside a fenced code block. */ readonly syntax: SyntaxTheme /** Background for user messages / prompts (Grok bg_light). */ @@ -110,7 +126,13 @@ export interface SyntaxTheme { /** A theme that emits no sequences, used off a TTY and under `NO_COLOR`. */ const PLAIN: Theme = { colored: false, - setLight: () => {}, + setting: 'auto', + resolved: undefined, + bands: false, + cursor: undefined, + setLight: () => false, + setTheme: () => false, + sgr: () => '', dim: text => text, bold: text => text, strike: text => text, @@ -125,6 +147,8 @@ const PLAIN: Theme = { warn: text => text, tool: text => text, path: text => text, + heading: text => text, + link: text => text, error: text => text, success: text => text, pending: text => text, @@ -148,6 +172,47 @@ const PLAIN: Theme = { diffDel: text => text, } +/** One palette resolved for one terminal: the escape each slot paints. */ +interface Resolved { + readonly spec: PaletteSpec + readonly seq: Readonly<Record<Slot, string>> + readonly diffAdd: string + readonly diffDel: string +} + +/** Every slot, for resolving a palette whole. */ +const SLOTS: readonly Slot[] = [ + 'accent', 'accent_soft', 'fg_dim', 'fg_muted', 'border', 'success', 'error', 'warning', 'tool', + 'diff_insert_fg', 'diff_delete_fg', + 'syntax_keyword', 'syntax_type', 'syntax_fn', 'syntax_string', 'syntax_number', 'syntax_comment', 'syntax_property', + 'logo_chevron', 'logo_hull', 'logo_water', + 'bg_light', 'bg_dark', 'bg_thinking', 'bg_error', 'md_code_bg', 'bg_meta', 'bg_hover', + 'diff_insert_bg', 'diff_delete_bg', +] + +/** + * The escapes a palette paints at a depth. A 16-colour-only palette paints its + * sixteen colours whatever the terminal could do. + */ +function resolvePalette(spec: PaletteSpec, depth: Depth): Resolved { + const at: Depth = spec.ansiOnly ? '16' : depth + const raw = (slot: Slot): string => params(spec.slots[slot], isBackground(slot) ? 'bg' : 'fg', at) + const escape = (codes: string): string => codes === '' ? '' : `\u001B[${codes}m` + const seq = Object.fromEntries(SLOTS.map(slot => [slot, escape(raw(slot))])) as Record<Slot, string> + const pair = (bg: Slot, fg: Slot): string => escape([raw(bg), raw(fg)].filter(codes => codes !== '').join(';')) + return { spec, seq, diffAdd: pair('diff_insert_bg', 'diff_insert_fg'), diffDel: pair('diff_delete_bg', 'diff_delete_fg') } +} + +/** + * The depth a terminal advertises: truecolor when `COLORTERM` says so, the + * 256-colour palette when `TERM` or any `COLORTERM` does, sixteen otherwise. + */ +function depthOf(env: Record<string, string | undefined>): Depth { + if (env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit') return 'truecolor' + if (env.TERM?.includes('256color') === true || env.COLORTERM !== undefined) return '256' + return '16' +} + /** * Build the theme for one surface. * @@ -159,26 +224,35 @@ const PLAIN: Theme = { * `dim` attribute: several terminals render `dim` at full brightness, and a * hierarchy nobody can see is no hierarchy — the placeholder, the menu details, * and the status row must sit visibly behind what the person typed. + * + * The record is built once and never replaced: renderers keep references to + * its roles (`theme.bold`, `theme.syntax`), so a theme switch changes what + * those same functions paint rather than handing out new ones. * @param isTty - whether the output stream is a terminal. * @param env - the environment to read `NO_COLOR` and the color depth from. + * @param initial - the theme choice to start with. * @returns the styling functions for this surface. */ -export function createTheme(isTty: boolean, env: Record<string, string | undefined>): Theme { +export function createTheme(isTty: boolean, env: Record<string, string | undefined>, initial: ThemeSetting = 'auto'): Theme { if (!isTty || env.NO_COLOR !== undefined) return PLAIN - const palette = env.TERM?.includes('256color') === true || env.COLORTERM !== undefined - const truecolor = env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' - const wrap = (code: string) => (text: string): string => `${code}${text}${SGR.reset}` + const depth = depthOf(env) // Mutable on purpose: the background answer arrives moments after the first - // frame, and everything rendered from then on picks the readable shade. - let isLight = false - let gray = '\u001B[38;5;245m' - let amber = '\u001B[38;5;172m' - const err = wrap(SGR.red) - const ok = wrap(SGR.green) - const warn = (text: string): string => `${palette ? amber : SGR.brightYellow}${text}${SGR.reset}` - const tool = (text: string): string => `${palette ? amber : SGR.yellow}${text}${SGR.reset}` - const agent = wrap(SGR.magenta) + // frame, and a person can switch theme mid-session; everything rendered + // from then on picks the new palette. + let setting: ThemeSetting = initial + let light = false + const pick = (): PaletteSpec => setting === 'auto' ? (light ? DEEPSEEK_LIGHT : DEEPSEEK) : PALETTES[setting] + let active = resolvePalette(pick(), depth) + const refresh = (): boolean => { + const next = pick() + if (next === active.spec) return false + active = resolvePalette(next, depth) + return true + } + const paint = (seq: string, text: string): string => seq === '' ? text : `${seq}${text}${SGR.reset}` + const role = (slot: Slot) => (text: string): string => paint(active.seq[slot], text) + const wrap = (code: string) => (text: string): string => `${code}${text}${SGR.reset}` const wrapBg = (getSeq: () => string) => (text: string): string => { if (text === '') return '' const seq = getSeq() @@ -186,71 +260,30 @@ export function createTheme(isTty: boolean, env: Record<string, string | undefin const inner = text.endsWith(SGR.reset) ? text.slice(0, -SGR.reset.length) : text return `${seq}${inner.replaceAll(SGR.reset, `${SGR.reset}${seq}`)}${SGR.reset}` } + const background = (slot: Slot) => wrapBg(() => active.seq[slot]) - const getBgUser = (): string => isLight - ? (truecolor ? BG_USER_LIGHT_TRUECOLOR : palette ? BG_USER_LIGHT_256 : '\u001B[47m') - : (truecolor ? BG_USER_DARK_TRUECOLOR : palette ? BG_USER_DARK_256 : '\u001B[40m') - - const getBgTool = (): string => isLight - ? (truecolor ? '\u001B[48;2;243;245;248m' : palette ? '\u001B[48;5;255m' : '\u001B[47m') - : (truecolor ? '\u001B[48;2;14;18;24m' : palette ? '\u001B[48;5;235m' : '\u001B[40m') - - const getBgThinking = (): string => isLight - ? (truecolor ? '\u001B[48;2;246;243;252m' : palette ? '\u001B[48;5;255m' : '\u001B[47m') - : (truecolor ? '\u001B[48;2;32;28;44m' : palette ? '\u001B[48;5;237m' : '\u001B[40m') - - const getBgError = (): string => isLight - ? (truecolor ? '\u001B[48;2;254;226;226m' : palette ? '\u001B[48;5;224m' : '\u001B[41m') - : (truecolor ? '\u001B[48;2;45;15;25m' : palette ? '\u001B[48;5;52m' : '\u001B[41m') - - const getBgCode = (): string => isLight - ? (truecolor ? '\u001B[48;2;240;242;246m' : palette ? '\u001B[48;5;254m' : '\u001B[47m') - : (truecolor ? '\u001B[48;2;15;18;24m' : palette ? '\u001B[48;5;235m' : '\u001B[40m') - - const getBgMeta = (): string => isLight - ? (truecolor ? '\u001B[48;2;244;244;246m' : palette ? '\u001B[48;5;255m' : '\u001B[47m') - : (truecolor ? '\u001B[48;2;18;20;26m' : palette ? '\u001B[48;5;236m' : '\u001B[40m') - - const getDiffAdd = (): string => isLight - ? (truecolor ? '\u001B[48;2;236;253;245;38;2;22;101;52m' : palette ? '\u001B[48;5;194;38;5;28m' : '\u001B[42;30m') - : (truecolor ? '\u001B[48;2;10;38;30;38;2;80;200;140m' : palette ? '\u001B[48;5;22;38;5;120m' : '\u001B[42;30m') - - const getDiffDel = (): string => isLight - ? (truecolor ? '\u001B[48;2;254;242;242;38;2;153;27;27m' : palette ? '\u001B[48;5;224;38;5;160m' : '\u001B[41;37m') - : (truecolor ? '\u001B[48;2;45;15;25;38;2;240;100;110m' : palette ? '\u001B[48;5;52;38;5;203m' : '\u001B[41;37m') - - const getKeywordColor = (): string => truecolor - ? (isLight ? '\u001B[38;2;175;0;219m' : '\u001B[38;2;197;134;192m') - : palette ? '\u001B[38;5;176m' : SGR.magenta - - const getTypeColor = (): string => truecolor - ? (isLight ? '\u001B[38;2;38;127;153m' : '\u001B[38;2;78;201;176m') - : palette ? '\u001B[38;5;73m' : SGR.cyan - - const getFnColor = (): string => truecolor - ? (isLight ? '\u001B[38;2;121;94;38m' : '\u001B[38;2;220;220;170m') - : palette ? '\u001B[38;5;186m' : SGR.yellow - - const getStringColor = (): string => truecolor - ? (isLight ? '\u001B[38;2;163;21;21m' : '\u001B[38;2;206;145;120m') - : palette ? '\u001B[38;5;173m' : SGR.green - - const getNumberColor = (): string => truecolor - ? (isLight ? '\u001B[38;2;9;134;88m' : '\u001B[38;2;181;206;168m') - : palette ? '\u001B[38;5;151m' : SGR.cyan - - const getPropertyColor = (): string => truecolor - ? (isLight ? '\u001B[38;2;0;16;128m' : '\u001B[38;2;156;220;254m') - : palette ? '\u001B[38;5;117m' : SGR.blue + const accent = role('accent') + const agent = role('accent_soft') + const err = role('error') + const ok = role('success') + const warn = role('warning') return { colored: true, - setLight(light: boolean) { - isLight = light - gray = light ? '\u001B[38;5;242m' : '\u001B[38;5;245m' - amber = light ? '\u001B[38;5;130m' : '\u001B[38;5;172m' + get setting() { return setting }, + get resolved() { return active.spec.name }, + get bands() { return active.spec.bands }, + get cursor() { return active.spec.cursor }, + setLight(next: boolean) { + light = next + return refresh() + }, + setTheme(next: ThemeSetting) { + setting = next + return refresh() }, - dim: text => `${palette ? gray : SGR.dim}${text}${SGR.reset}`, + sgr: slot => active.seq[slot], + dim: role('fg_dim'), bold: wrap(SGR.bold), strike: wrap(SGR.strike), italic: wrap(SGR.italic), @@ -262,38 +295,41 @@ export function createTheme(isTty: boolean, env: Record<string, string | undefin // theme paints success in, on this person's palette, light or dark. if ('ansi' in parsed) return wrap(`\u001B[${parsed.ansi}m`) const [red, green, blue] = parsed.rgb - if (truecolor) return wrap(`\u001B[38;2;${red};${green};${blue}m`) - if (palette) return wrap(`\u001B[38;5;${nearest256(red, green, blue)}m`) + const at: Depth = active.spec.ansiOnly ? '16' : depth + if (at === 'truecolor') return wrap(`\u001B[38;2;${red};${green};${blue}m`) + if (at === '256') return wrap(`\u001B[38;5;${nearest256(red, green, blue)}m`) return wrap(`\u001B[${nearestAnsi(red, green, blue)}m`) }, - muted: wrap(SGR.brightBlack), - accent: wrap(SGR.cyan), + muted: role('fg_muted'), + accent, agent, err, ok, warn, - tool, - path: wrap(SGR.blue), + tool: role('tool'), + path: agent, + heading: agent, + link: agent, error: err, success: ok, pending: warn, - user: agent, - bgUser: wrapBg(getBgUser), - bgTool: wrapBg(getBgTool), - bgThinking: wrapBg(getBgThinking), - bgError: wrapBg(getBgError), - bgCode: wrapBg(getBgCode), - bgMeta: wrapBg(getBgMeta), - diffAdd: wrapBg(getDiffAdd), - diffDel: wrapBg(getDiffDel), + user: accent, + bgUser: background('bg_light'), + bgTool: background('bg_dark'), + bgThinking: background('bg_thinking'), + bgError: background('bg_error'), + bgCode: background('md_code_bg'), + bgMeta: background('bg_meta'), + diffAdd: wrapBg(() => active.diffAdd), + diffDel: wrapBg(() => active.diffDel), syntax: { - keyword: text => `${getKeywordColor()}${text}${SGR.reset}`, - type: text => `${getTypeColor()}${text}${SGR.reset}`, - fn: text => `${getFnColor()}${text}${SGR.reset}`, - string: text => `${getStringColor()}${text}${SGR.reset}`, - number: text => `${getNumberColor()}${text}${SGR.reset}`, - comment: wrap(SGR.dim), - property: text => `${getPropertyColor()}${text}${SGR.reset}`, + keyword: role('syntax_keyword'), + type: role('syntax_type'), + fn: role('syntax_fn'), + string: role('syntax_string'), + number: role('syntax_number'), + comment: role('syntax_comment'), + property: role('syntax_property'), }, } } @@ -352,42 +388,6 @@ export function parseColor(spec: string): ColorSpec | undefined { return undefined } -/** The sixteen ANSI colours as xterm paints them, with their SGR foreground codes. */ -const ANSI_PALETTE: readonly [number, number, number, string][] = [ - [0, 0, 0, '30'], [205, 0, 0, '31'], [0, 205, 0, '32'], [205, 205, 0, '33'], - [0, 0, 238, '34'], [205, 0, 205, '35'], [0, 205, 205, '36'], [229, 229, 229, '37'], - [127, 127, 127, '90'], [255, 0, 0, '91'], [0, 255, 0, '92'], [255, 255, 0, '93'], - [92, 92, 255, '94'], [255, 0, 255, '95'], [0, 255, 255, '96'], [255, 255, 255, '97'], -] - -/** Squared distance between two sRGB points; ordering is all that is needed. */ -const apart = (r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number => - (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 - -/** The SGR code of the ANSI colour closest to an sRGB point. */ -function nearestAnsi(red: number, green: number, blue: number): string { - let best = ANSI_PALETTE[0] ?? [0, 0, 0, '37'] - for (const candidate of ANSI_PALETTE) { - if (apart(red, green, blue, candidate[0], candidate[1], candidate[2]) < apart(red, green, blue, best[0], best[1], best[2])) best = candidate - } - return best[3] -} - -/** Levels of the 256-colour palette's 6×6×6 cube. */ -const CUBE = [0, 95, 135, 175, 215, 255] - -/** The 256-colour index closest to an sRGB point: the cube, or the gray ramp when that is nearer. */ -function nearest256(red: number, green: number, blue: number): number { - const level = (value: number): number => - CUBE.reduce((best, candidate, index) => Math.abs(candidate - value) < Math.abs((CUBE[best] ?? 0) - value) ? index : best, 0) - const [ri, gi, bi] = [level(red), level(green), level(blue)] - const cubeApart = apart(red, green, blue, CUBE[ri] ?? 0, CUBE[gi] ?? 0, CUBE[bi] ?? 0) - // The ramp runs 232..255 at 8, 18, … 238. - const step = Math.min(23, Math.max(0, Math.round((Math.round((red + green + blue) / 3) - 8) / 10))) - const gray = 8 + 10 * step - return apart(red, green, blue, gray, gray, gray) < cubeApart ? 232 + step : 16 + 36 * ri + 6 * gi + bi -} - /** * Whether an OSC 10/11 color answer names a light color. * diff --git a/packages/bundle/src/transcript.ts b/packages/bundle/src/transcript.ts index c66db93f..69e91ec6 100644 --- a/packages/bundle/src/transcript.ts +++ b/packages/bundle/src/transcript.ts @@ -243,7 +243,7 @@ export function childSessionId(text: string): string | undefined { * behind it. * * Pages of reasoning would bury the conversation, so the transcript keeps a - * one-line summary — the magenta `│` lives in the agent gutter via + * one-line summary — the tint-blue `│` lives in the agent gutter via * {@link blockRules}, with no panel fill — and hands the filled body to the * fold (click or Ctrl+O). * @param lines - the rendered thinking lines, already styled. @@ -757,7 +757,8 @@ export class Transcript { // The panel's padding is the screen's to place: it wraps the block // rather than joining it, so the navigation seam, the fold, and the // pinned copy all stay the text the person actually typed. - this.promptPad = theme.colored ? theme.bgUser(' ') : undefined + // A theme that paints no bands sets the message in bold, with no pad rows. + this.promptPad = theme.bands ? theme.bgUser(' ') : undefined const lines = [ theme.bgUser(`${cardIndent(theme)}${first}`), ...rest.map(line => theme.bgUser(` ${line}`)), diff --git a/packages/bundle/tests/banner.spec.ts b/packages/bundle/tests/banner.spec.ts index fc2ecd8e..dfd6a2c4 100644 --- a/packages/bundle/tests/banner.spec.ts +++ b/packages/bundle/tests/banner.spec.ts @@ -83,3 +83,29 @@ describe('bannerLines', () => { expect(lines.some(line => line.includes('Welcome to codsh'))).toBe(true) }) }) + +describe('the mark, by theme and depth', () => { + const logo = (theme: ReturnType<typeof createTheme>): string => + bannerLines(facts, theme, 100).filter(line => /[█▀▄]/u.test(line)).join('\n') + + it('inks the whale in the brand and its tint on a 256-colour terminal, with ✻ in the brand', () => { + const theme = createTheme(true, { TERM: 'xterm-256color' }) + expect(logo(theme)).toContain('\u001B[38;5;105m') + expect(logo(theme)).toContain('\u001B[38;5;63m') + expect(logo(theme)).not.toContain('\u001B[38;2;') + expect(bannerLines(facts, theme, 100).join('\n')).toContain('\u001B[38;5;63m✻') + }) + + it('keeps the hull visible on a light background', () => { + const theme = createTheme(true, { TERM: 'xterm-256color' }, 'deepseek-light') + expect(logo(theme)).toContain('\u001B[38;5;17m') + expect(logo(theme)).not.toContain('\u001B[38;5;255m') + }) + + it('paints the terminal theme in ANSI colours only, and truecolor as the logo file does', () => { + const terminal = createTheme(true, { COLORTERM: 'truecolor' }, 'terminal') + expect(logo(terminal)).not.toContain('38;2;') + expect(logo(terminal)).toContain('\u001B[36m') + expect(logo(createTheme(true, { COLORTERM: 'truecolor' }))).toContain('\u001B[38;2;77;107;254m') + }) +}) diff --git a/packages/bundle/tests/frontier-card.spec.ts b/packages/bundle/tests/frontier-card.spec.ts index 89a57290..940ff547 100644 --- a/packages/bundle/tests/frontier-card.spec.ts +++ b/packages/bundle/tests/frontier-card.spec.ts @@ -288,10 +288,10 @@ describe('FrontierCard', () => { const card = new FrontierCard(spec) const text = card.frame(painted, 56).rows.join('\n') expect(text).toContain('\u001B[32m') - expect(text).toContain('\u001B[36m') - // Footer: y is ok (green), arrows are accent (cyan); no abort red. + expect(text).toContain('\u001B[94m') + // Footer: y is ok (green), arrows are accent (DeepSeek blue); no abort red. expect(text).toContain('\u001B[32m[y]') - expect(text).toContain('\u001B[36m[↑↓]') + expect(text).toContain('\u001B[94m[↑↓]') expect(text).not.toContain('\u001B[31m') }) diff --git a/packages/bundle/tests/inputbox.spec.ts b/packages/bundle/tests/inputbox.spec.ts index 94ff87c0..6d6f1941 100644 --- a/packages/bundle/tests/inputbox.spec.ts +++ b/packages/bundle/tests/inputbox.spec.ts @@ -176,7 +176,7 @@ describe('visual hierarchy', () => { column: 17, hits: [{ row: 0, start: 4, end: 13, kind: 'skill' }], }), colour, 60) - expect(skill.rows[1]).toContain('\u001B[35m$grill-me\u001B[0m') + expect(skill.rows[1]).toContain('\u001B[38;5;105m$grill-me\u001B[0m') expect(skill.rows[1]).toContain('use ') expect(skill.rows[1]).toContain(' now') }) @@ -190,10 +190,11 @@ describe('visual hierarchy', () => { selected: 0, }), colour, 60) const [chosen = '', other = ''] = overlay - expect(chosen).toContain('❯') + expect(chosen).toContain('\u001B[38;5;63m❯') expect(chosen).toContain('\u001B[4m/p\u001B[24m') expect(chosen).toContain('lan') - expect(chosen).not.toContain('\u001B[36m') + // The marker carries the accent; the candidate's own text does not. + expect(chosen.replace('\u001B[38;5;63m❯', '')).not.toContain('\u001B[38;5;63m') expect(chosen).toContain(`${gray} enter plan mode`) expect(other).not.toContain('❯') expect(other).toContain('\u001B[4m/p\u001B[24m') diff --git a/packages/bundle/tests/markdown.spec.ts b/packages/bundle/tests/markdown.spec.ts index 4c1b4574..8b2639aa 100644 --- a/packages/bundle/tests/markdown.spec.ts +++ b/packages/bundle/tests/markdown.spec.ts @@ -106,8 +106,8 @@ describe('inline HTML in an answer', () => { // The styles open at the break — the heading's and the tag's — carry onto the next row. const heading = renderMarkdown('# <font color="red">a<br>b</font>', colour) expect(heading).toHaveLength(2) - expect(heading[0]).toContain('\u001B[93m\u001B[31ma\u001B[0m') - expect(heading[1]?.startsWith('\u001B[93m\u001B[31mb\u001B[0m')).toBe(true) + expect(heading[0]).toContain('\u001B[94m\u001B[31ma\u001B[0m') + expect(heading[1]?.startsWith('\u001B[94m\u001B[31mb\u001B[0m')).toBe(true) }) it('lets a <br> inside a table cell make a second row of the cell', () => { diff --git a/packages/bundle/tests/prefs.spec.ts b/packages/bundle/tests/prefs.spec.ts new file mode 100644 index 00000000..4b439e75 --- /dev/null +++ b/packages/bundle/tests/prefs.spec.ts @@ -0,0 +1,59 @@ +/** + * The surface's preferences file: every write merges, so one preference never + * erases another, and a junk or missing file reads as empty. + */ + +import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { loadDensity, saveDensity } from '../src/density.ts' +import { mergePrefs, readPrefs } from '../src/prefs.ts' +import { loadThemeSetting, saveThemeSetting } from '../src/theme-setting.ts' + +const prefsFile = async (): Promise<string> => join(await mkdtemp(join(tmpdir(), 'codsh-prefs-')), 'code-cli-ui.json') + +describe('the prefs file', () => { + it('keeps the density when the theme is saved, and the theme when the density is', async () => { + const path = await prefsFile() + await saveDensity(path, 'comfortable') + await saveThemeSetting(path, 'deepseek-light') + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ density: 'comfortable', theme: 'deepseek-light' }) + await saveDensity(path, 'compact') + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ density: 'compact', theme: 'deepseek-light' }) + expect(await loadDensity(path)).toBe('compact') + expect(await loadThemeSetting(path)).toBe('deepseek-light') + }) + + it('keeps keys it does not know, which a newer version may have written', async () => { + const path = await prefsFile() + await writeFile(path, '{"density":"comfortable","future":{"x":1}}\n') + await saveThemeSetting(path, 'terminal') + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ density: 'comfortable', future: { x: 1 }, theme: 'terminal' }) + }) + + it('reads a legacy density-only file', async () => { + const path = await prefsFile() + await writeFile(path, '{"density":"comfortable"}\n') + expect(await loadDensity(path)).toBe('comfortable') + expect(await loadThemeSetting(path)).toBeUndefined() + }) + + it('reads a missing, junk, or non-object file as empty, and replaces it on the next write', async () => { + const path = await prefsFile() + expect(await readPrefs(path)).toEqual({}) + for (const junk of ['not json', '[1,2]', 'null', '"x"']) { + await writeFile(path, junk) + expect(await readPrefs(path)).toEqual({}) + } + await mergePrefs(path, { theme: 'auto' }) + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ theme: 'auto' }) + }) + + it('leaves no staging file behind', async () => { + const path = await prefsFile() + await mergePrefs(path, { theme: 'auto' }) + await mergePrefs(path, { density: 'compact' }) + expect(await readdir(join(path, '..'))).toEqual(['code-cli-ui.json']) + }) +}) diff --git a/packages/bundle/tests/prompt.spec.ts b/packages/bundle/tests/prompt.spec.ts index e7cc85e9..a45981f4 100644 --- a/packages/bundle/tests/prompt.spec.ts +++ b/packages/bundle/tests/prompt.spec.ts @@ -344,6 +344,22 @@ describe('selection', () => { expect(highlighted).toEqual([0, 1]) }) + it('tells the caller the outcome before the frame that closes the selection', async () => { + const { prompt, console } = build() + let drawsAtSettle = -1 + const deciding = prompt.select({ title: 'Theme', options: [{ label: 'auto' }, { label: 'terminal' }] }, undefined, undefined, (outcome) => { + expect(outcome).toEqual({ kind: 'cancelled' }) + drawsAtSettle = console.draws.length + }) + console.press({ kind: 'escape' }) + expect(await deciding).toEqual({ kind: 'cancelled' }) + // Heard while the selector was still the last thing drawn; the box came back after. + expect(drawsAtSettle).toBeGreaterThan(0) + expect(console.draws.length).toBeGreaterThan(drawsAtSettle) + expect(console.draws[drawsAtSettle - 1]?.rows[0]).toBe('Theme') + expect(console.draws.at(-1)?.rows[0]).not.toBe('Theme') + }) + it('settles on a row pressed and released in the same place', async () => { const { prompt, console } = build() const deciding = prompt.select({ title: 'Allow?', options: [{ label: 'Yes' }, { label: 'No' }] }) diff --git a/packages/bundle/tests/screen.spec.ts b/packages/bundle/tests/screen.spec.ts index f5dcb5f2..3d0bb6f2 100644 --- a/packages/bundle/tests/screen.spec.ts +++ b/packages/bundle/tests/screen.spec.ts @@ -9,7 +9,7 @@ import { describe, expect, it, vi } from 'vitest' import { Screen } from '../src/screen.ts' import type { ScreenHost } from '../src/screen.ts' import type { TerminalGraphic } from '../src/terminal-graphics.ts' -import { displayWidth } from '../src/theme.ts' +import { createTheme, displayWidth } from '../src/theme.ts' /** A host that records what would be written, at a fixed size. */ function host(rows = 10, columns = 20): ScreenHost & { out: string[]; size: { rows: number; columns: number } } { @@ -1121,18 +1121,18 @@ describe('scrolling', () => { it('fills sticky header rows with the panel background and adds a divider line', () => { const sink = host(6, 40) - const screen = new Screen(sink) + const screen = new Screen(sink, { TERM: 'xterm-256color' }) screen.enter() screen.setChrome(['status'], { row: 0, column: 0 }, false) screen.appendPrompt(['› question', ''], '| ') screen.append(Array.from({ length: 12 }, (_, index) => `answer ${index}`)) screen.scrollBy(-2) const frame = flush(sink) - expect(frame).toContain('\u001B[48;5;53m| › question') + expect(frame).toContain('\u001B[48;5;17m| › question') expect(frame).toContain('\u001B[90m─') }) - it('uses truecolor deep plum fill for sticky header when COLORTERM is truecolor', () => { + it('uses the truecolor navy band for sticky header when COLORTERM is truecolor', () => { const sink = host(6, 40) const screen = new Screen(sink, { COLORTERM: 'truecolor' }) screen.enter() @@ -1141,7 +1141,7 @@ describe('scrolling', () => { screen.append(Array.from({ length: 12 }, (_, index) => `answer ${index}`)) screen.scrollBy(-2) const frame = flush(sink) - expect(frame).toContain('\u001B[48;2;30;19;38m| › question') + expect(frame).toContain('\u001B[48;2;21;26;48m| › question') expect(frame).toContain('\u001B[90m─') }) @@ -1193,7 +1193,7 @@ describe('scrolling', () => { it('uses light panel fill for sticky header on a light background', () => { const sink = host(6, 40) - const screen = new Screen(sink) + const screen = new Screen(sink, { TERM: 'xterm-256color' }) screen.enter() screen.setLight(true) screen.setChrome(['status'], { row: 0, column: 0 }, false) @@ -1201,10 +1201,10 @@ describe('scrolling', () => { screen.append(Array.from({ length: 12 }, (_, index) => `answer ${index}`)) screen.scrollBy(-2) const frame = flush(sink) - expect(frame).toContain('\u001B[48;5;225m| › question') + expect(frame).toContain('\u001B[48;5;189m| › question') }) - it('uses truecolor lavender fill for sticky header on a light background when COLORTERM is truecolor', () => { + it('uses the truecolor light band for sticky header on a light background when COLORTERM is truecolor', () => { const sink = host(6, 40) const screen = new Screen(sink, { COLORTERM: 'truecolor' }) screen.enter() @@ -1214,7 +1214,7 @@ describe('scrolling', () => { screen.append(Array.from({ length: 12 }, (_, index) => `answer ${index}`)) screen.scrollBy(-2) const frame = flush(sink) - expect(frame).toContain('\u001B[48;2;243;234;246m| › question') + expect(frame).toContain('\u001B[48;2;238;241;251m| › question') }) it('returns to the latest when the notice row is clicked', () => { @@ -3404,3 +3404,103 @@ describe('overlay graphics', () => { expect(hoverCount).toBeGreaterThanOrEqual(3) }) }) + +describe('theme-driven fills', () => { + /** A screen scrolled far enough that its prompt is pinned as the sticky header. */ + const pinnedSticky = (screen: Screen): void => { + screen.enter() + screen.setChrome(['status'], { row: 0, column: 0 }, false) + screen.appendPrompt(['› question', ''], '| ') + screen.append(Array.from({ length: 12 }, (_, index) => `answer ${index}`)) + screen.scrollBy(-2) + } + + it('paints no fill and no colour under NO_COLOR', () => { + const sink = host(6, 40) + const screen = new Screen(sink, { NO_COLOR: '1', TERM: 'xterm-256color' }) + pinnedSticky(screen) + const frame = flush(sink) + expect(frame).toContain('| › question') + expect(frame).not.toMatch(/\u001B\[48;/u) + expect(frame).not.toContain('\u001B[90m') + expect(frame).toContain('─'.repeat(10)) + }) + + it('lifts no hovered block under NO_COLOR', () => { + const sink = host(10, 40) + const screen = new Screen(sink, { NO_COLOR: '1' }) + screen.enter() + screen.setChrome(['status'], { row: 0, column: 0 }, false) + screen.appendFold(['summary', ''], ['full'], '', 'thinking') + flush(sink) + expect(screen.mouseMove(1, 3)).toEqual({ label: 'thinking', lines: 0, expanded: false }) + expect(flush(sink)).not.toMatch(/\u001B\[48;/u) + }) + + it('pads the sticky header without a band under the terminal theme', () => { + const sink = host(6, 40) + const screen = new Screen(sink, { TERM: 'xterm-256color' }) + screen.useTheme(createTheme(true, { TERM: 'xterm-256color' }, 'terminal')) + pinnedSticky(screen) + const frame = flush(sink) + expect(frame).toContain('| › question') + expect(frame).not.toMatch(/\u001B\[48;/u) + expect(frame).toContain('\u001B[90m─') + }) + + it('repaints the sticky band in the new palette when the shared theme switches', () => { + const sink = host(6, 40) + const screen = new Screen(sink, { TERM: 'xterm-256color' }) + const theme = createTheme(true, { TERM: 'xterm-256color' }) + screen.useTheme(theme) + pinnedSticky(screen) + expect(flush(sink)).toContain('\u001B[48;5;17m| › question') + theme.setTheme('deepseek-light') + screen.restyle() + expect(flush(sink)).toContain('\u001B[48;5;189m| › question') + }) + + it('colours the cursor with the brand on entry and gives it back on leaving', () => { + const sink = host(6, 40) + const screen = new Screen(sink, { TERM: 'xterm-256color' }) + screen.enter() + expect(flush(sink)).toContain('\u001B]12;#4d6bfe\u0007') + screen.leave() + const left = flush(sink) + expect(left).toContain('\u001B]112\u0007') + expect(left.indexOf('\u001B]112\u0007')).toBeLessThan(left.indexOf('\u001B[?1049l')) + }) + + it('leaves the cursor colour alone under NO_COLOR and the terminal theme', () => { + for (const setup of [ + (sink: ReturnType<typeof host>) => new Screen(sink, { NO_COLOR: '1' }), + (sink: ReturnType<typeof host>) => { + const screen = new Screen(sink, {}) + screen.useTheme(createTheme(true, {}, 'terminal')) + return screen + }, + ]) { + const sink = host(6, 40) + const screen = setup(sink) + screen.enter() + screen.leave() + const out = flush(sink) + expect(out).not.toContain('\u001B]12;') + expect(out).not.toContain('\u001B]112') + } + }) + + it('gives the cursor colour back when a switch lands on a theme that sets none', () => { + const sink = host(6, 40) + const screen = new Screen(sink, {}) + const theme = createTheme(true, {}) + screen.useTheme(theme) + screen.enter() + flush(sink) + theme.setTheme('terminal') + screen.restyle() + expect(flush(sink)).toContain('\u001B]112\u0007') + screen.leave() + expect(flush(sink)).not.toContain('\u001B]112') + }) +}) diff --git a/packages/bundle/tests/status.spec.ts b/packages/bundle/tests/status.spec.ts index f87d4a45..0386ced9 100644 --- a/packages/bundle/tests/status.spec.ts +++ b/packages/bundle/tests/status.spec.ts @@ -450,7 +450,7 @@ describe('status styling', () => { it('styles the landing chip agent, and ok while a ticket flash is on', () => { const land = statusLine({ ...base, shipChip: { kind: 'land', k: 2, n: 3 } }, colour, 200) - expect(land).toContain('\u001B[35mship · land 2/3\u001B[0m') + expect(land).toContain('\u001B[94mship · land 2/3\u001B[0m') const flash = statusLine({ ...base, shipChip: { kind: 'land', k: 2, n: 3, flashOk: true } }, colour, 200) expect(flash).toContain('\u001B[32mship · land 2/3\u001B[0m') }) @@ -476,10 +476,12 @@ describe('status styling', () => { expect(paintShipChip({ kind: 'land', k: 1, n: 4 }, plain)).toBe('ship · land 1/4') }) - it('styles the model muted, never cyan/accent', () => { + it('styles the model muted, never accent', () => { const line = statusLine({ ...base, preset: 'code-cli', usage }, colour, 200) expect(line).toContain('\u001B[90mm\u001B[0m') - expect(line).not.toContain('\u001B[36m') + const [accentOpen = ''] = colour.accent('x').split('x') + expect(accentOpen).not.toBe('') + expect(line).not.toContain(accentOpen) }) it('styles the model with reasoning effort muted', () => { diff --git a/packages/bundle/tests/theme-picker.spec.ts b/packages/bundle/tests/theme-picker.spec.ts new file mode 100644 index 00000000..9815bb52 --- /dev/null +++ b/packages/bundle/tests/theme-picker.spec.ts @@ -0,0 +1,154 @@ +/** + * The `/theme` picker previews each theme as the marker moves — the chrome at + * once, the history once the marker rests — and Esc puts the original back. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SelectOutcome, SelectSpec } from '../src/selector.ts' +import { createTheme } from '../src/theme.ts' +import type { ThemeSetting } from '../src/theme.ts' +import { HistoryRepaint, PREVIEW_REPAINT_MS, SLOW_REPAINT_MS, pickTheme } from '../src/theme-picker.ts' + +/** A surface whose history knows which palette it was painted in. */ +function surface(options: { busy?: boolean; repaintMs?: number } = {}) { + const theme = createTheme(true, { TERM: 'xterm-256color' }) + let painted = theme.resolved + let busy = options.busy ?? false + const runs: (string | undefined)[] = [] + const history = new HistoryRepaint({ + stale: () => painted !== theme.resolved, + busy: () => busy, + run: () => { + painted = theme.resolved + runs.push(painted) + return options.repaintMs ?? 5 + }, + }) + return { + theme, + history, + runs, + setBusy: (next: boolean) => { busy = next }, + apply: (setting: ThemeSetting) => theme.setTheme(setting), + } +} + +/** A selection that moves the marker through `marks`, then ends with `outcome`. */ +function scripted(marks: number[], outcome: SelectOutcome, onSpec?: (spec: SelectSpec) => void) { + return async ( + spec: SelectSpec, + _signal: AbortSignal | undefined, + preview: (index: number) => void, + settled: (outcome: SelectOutcome) => void, + ): Promise<SelectOutcome> => { + onSpec?.(spec) + for (const index of marks) { + preview(index) + await vi.advanceTimersByTimeAsync(PREVIEW_REPAINT_MS + 1) + } + settled(outcome) + return outcome + } +} + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { vi.useRealTimers() }) + +describe('HistoryRepaint', () => { + it('repaints at once when stale and idle, and not at all when current', () => { + const s = surface() + s.history.request() + expect(s.runs).toEqual([]) + s.apply('deepseek-light') + s.history.request() + expect(s.runs).toEqual(['deepseek-light']) + }) + + it('holds a repaint while busy and runs it on flush', () => { + const s = surface({ busy: true }) + s.apply('terminal') + s.history.request() + s.history.flush() + expect(s.runs).toEqual([]) + s.setBusy(false) + s.history.flush() + expect(s.runs).toEqual(['terminal']) + s.history.flush() + expect(s.runs).toEqual(['terminal']) + }) + + it('debounces preview repaints to the one the marker rests on', async () => { + const s = surface() + s.apply('deepseek-light') + s.history.soon() + s.apply('terminal') + s.history.soon() + await vi.advanceTimersByTimeAsync(PREVIEW_REPAINT_MS - 1) + expect(s.runs).toEqual([]) + await vi.advanceTimersByTimeAsync(2) + expect(s.runs).toEqual(['terminal']) + }) + + it('skips preview repaints for a session too slow to repaint per keystroke', async () => { + const s = surface({ repaintMs: SLOW_REPAINT_MS + 1 }) + s.apply('deepseek-light') + s.history.request() + expect(s.runs).toEqual(['deepseek-light']) + s.apply('terminal') + s.history.soon() + await vi.advanceTimersByTimeAsync(PREVIEW_REPAINT_MS * 4) + expect(s.runs).toEqual(['deepseek-light']) + // The choice itself still repaints. + s.history.flush() + expect(s.runs).toEqual(['deepseek-light', 'terminal']) + }) +}) + +describe('pickTheme', () => { + it('lists the catalog with the current theme marked, and previews as the marker moves', async () => { + const s = surface() + let offered: SelectSpec | undefined + const chosen = await pickTheme({ + select: scripted([2, 3], { kind: 'chosen', indices: [3] }, (spec) => { offered = spec }), + current: () => s.theme.setting, + apply: s.apply, + history: s.history, + }) + expect(offered?.title).toBe('Theme') + expect(offered?.options.map(option => option.label)).toEqual(['auto', 'deepseek', 'deepseek-light', 'terminal']) + expect(offered?.options[0]?.detail).toContain('current') + expect(offered?.prior).toEqual({ selected: 'auto' }) + expect(chosen).toBe('terminal') + expect(s.theme.setting).toBe('terminal') + expect(s.runs).toEqual(['deepseek-light', 'terminal']) + }) + + it('puts the original back on Esc, repainting only what the preview changed', async () => { + const s = surface() + const chosen = await pickTheme({ + select: scripted([2], { kind: 'cancelled' }), + current: () => s.theme.setting, + apply: s.apply, + history: s.history, + }) + expect(chosen).toBeUndefined() + expect(s.theme.setting).toBe('auto') + expect(s.theme.resolved).toBe('deepseek') + expect(s.runs).toEqual(['deepseek-light', 'deepseek']) + }) + + it('previews the chrome under a running turn and leaves the history to the turn end', async () => { + const s = surface({ busy: true }) + const chosen = await pickTheme({ + select: scripted([2], { kind: 'chosen', indices: [2] }), + current: () => s.theme.setting, + apply: s.apply, + history: s.history, + }) + expect(chosen).toBe('deepseek-light') + expect(s.runs).toEqual([]) + s.setBusy(false) + s.history.flush() + expect(s.runs).toEqual(['deepseek-light']) + }) +}) diff --git a/packages/bundle/tests/theme-setting.spec.ts b/packages/bundle/tests/theme-setting.spec.ts new file mode 100644 index 00000000..f0ae5096 --- /dev/null +++ b/packages/bundle/tests/theme-setting.spec.ts @@ -0,0 +1,77 @@ +/** + * `/theme` names: forgiving the way Grok CLI's are, with `CODSH_THEME` picking + * one for a launch and a saved choice for the rest. + */ + +import { describe, expect, it } from 'vitest' +import { createTheme } from '../src/theme.ts' +import { THEME_CHOICES, choiceList, parseThemeSetting, startupThemeSetting, themeReport } from '../src/theme-setting.ts' + +describe('parseThemeSetting', () => { + it('reads every name, any case, padded', () => { + for (const choice of THEME_CHOICES) expect(parseThemeSetting(choice.name)).toBe(choice.name) + expect(parseThemeSetting(' DeepSeek-Light ')).toBe('deepseek-light') + expect(parseThemeSetting('AUTO')).toBe('auto') + }) + + it('answers to the aliases a person would type', () => { + expect(parseThemeSetting('system')).toBe('auto') + expect(parseThemeSetting('default')).toBe('auto') + expect(parseThemeSetting('DARK')).toBe('deepseek') + expect(parseThemeSetting('ds')).toBe('deepseek') + expect(parseThemeSetting(' Light ')).toBe('deepseek-light') + expect(parseThemeSetting('native')).toBe('terminal') + expect(parseThemeSetting('ansi')).toBe('terminal') + }) + + it('names nothing for anything else', () => { + expect(parseThemeSetting('')).toBeUndefined() + expect(parseThemeSetting('tokyonight')).toBeUndefined() + expect(parseThemeSetting('groknight')).toBeUndefined() + }) + + it('lists the four choices in picker order', () => { + expect(THEME_CHOICES.map(choice => choice.name)).toEqual(['auto', 'deepseek', 'deepseek-light', 'terminal']) + expect(choiceList()).toBe('auto, deepseek, deepseek-light, terminal') + }) +}) + +describe('startupThemeSetting', () => { + it('starts on auto with nothing saved or set', () => { + expect(startupThemeSetting({}, undefined)).toEqual({ setting: 'auto', fromEnv: false }) + }) + + it('takes the saved choice, and ignores a saved value that names none', () => { + expect(startupThemeSetting({}, 'terminal')).toEqual({ setting: 'terminal', fromEnv: false }) + expect(startupThemeSetting({}, 'light')).toEqual({ setting: 'deepseek-light', fromEnv: false }) + expect(startupThemeSetting({}, 'nope').setting).toBe('auto') + expect(startupThemeSetting({}, 42).setting).toBe('auto') + }) + + it('lets CODSH_THEME win over the saved choice', () => { + expect(startupThemeSetting({ CODSH_THEME: 'Light' }, 'terminal')).toEqual({ setting: 'deepseek-light', fromEnv: true }) + expect(startupThemeSetting({ CODSH_THEME: ' ' }, 'terminal')).toEqual({ setting: 'terminal', fromEnv: false }) + }) + + it('warns about a CODSH_THEME that names nothing, and falls back to the saved choice', () => { + const start = startupThemeSetting({ CODSH_THEME: 'solarized' }, 'deepseek') + expect(start.setting).toBe('deepseek') + expect(start.fromEnv).toBe(false) + expect(start.warning).toBe('CODSH_THEME="solarized" names no theme — auto, deepseek, deepseek-light, terminal') + }) +}) + +describe('themeReport', () => { + it('names the palette auto resolved to, and a named theme alone', () => { + const theme = createTheme(true, { TERM: 'xterm-256color' }) + expect(themeReport(theme)).toBe('theme · auto → deepseek') + theme.setLight(true) + expect(themeReport(theme)).toBe('theme · auto → deepseek-light') + theme.setTheme('terminal') + expect(themeReport(theme)).toBe('theme · terminal') + }) + + it('says colour is off when nothing is painted', () => { + expect(themeReport(createTheme(true, { NO_COLOR: '1' }))).toBe('theme · auto · colour is off (NO_COLOR or not a terminal)') + }) +}) diff --git a/packages/bundle/tests/theme.spec.ts b/packages/bundle/tests/theme.spec.ts index 37b016bd..95d86df2 100644 --- a/packages/bundle/tests/theme.spec.ts +++ b/packages/bundle/tests/theme.spec.ts @@ -4,6 +4,7 @@ */ import { describe, expect, it } from 'vitest' +import { nearest256, nearestAnsi, params } from '../src/palette.ts' import { backgroundIsLight, columnIndex, createTheme, displayWidth, graphemeAt, markSpan, oneRow, parseColor, truncate } from '../src/theme.ts' describe('createTheme', () => { @@ -26,14 +27,29 @@ describe('createTheme', () => { it('maps the locked semantic palette to the agreed SGR codes', () => { const theme = createTheme(true, {}) expect(theme.muted('x')).toBe('\u001B[90mx\u001B[0m') - expect(theme.accent('x')).toBe('\u001B[36mx\u001B[0m') - expect(theme.agent('x')).toBe('\u001B[35mx\u001B[0m') + expect(theme.accent('x')).toBe('\u001B[94mx\u001B[0m') + expect(theme.agent('x')).toBe('\u001B[94mx\u001B[0m') expect(theme.tool('x')).toBe('\u001B[33mx\u001B[0m') expect(theme.ok('x')).toBe('\u001B[32mx\u001B[0m') expect(theme.warn('x')).toBe('\u001B[93mx\u001B[0m') expect(theme.err('x')).toBe('\u001B[31mx\u001B[0m') }) + it('paints the DeepSeek brand blue and its tint by the depth the terminal has', () => { + const palette = createTheme(true, { TERM: 'xterm-256color' }) + expect(palette.accent('x')).toBe('\u001B[38;5;63mx\u001B[0m') + expect(palette.user('x')).toBe('\u001B[38;5;63mx\u001B[0m') + expect(palette.agent('x')).toBe('\u001B[38;5;105mx\u001B[0m') + expect(palette.path('x')).toBe('\u001B[38;5;105mx\u001B[0m') + expect(palette.heading('x')).toBe('\u001B[38;5;105mx\u001B[0m') + expect(palette.link('x')).toBe('\u001B[38;5;105mx\u001B[0m') + expect(palette.muted('x')).toBe('\u001B[90mx\u001B[0m') + const truecolor = createTheme(true, { COLORTERM: 'truecolor' }) + expect(truecolor.accent('x')).toBe('\u001B[38;2;77;107;254mx\u001B[0m') + expect(truecolor.agent('x')).toBe('\u001B[38;2;126;150;245mx\u001B[0m') + expect(truecolor.dim('x')).toBe('\u001B[38;5;245mx\u001B[0m') + }) + it('strips accent under NO_COLOR', () => { expect(createTheme(true, { NO_COLOR: '1' }).accent('x')).toBe('x') }) @@ -52,9 +68,9 @@ describe('createTheme', () => { it('renders grok background colors for different functional sections under truecolor', () => { const theme = createTheme(true, { COLORTERM: 'truecolor' }) - expect(theme.bgUser('prompt')).toBe('\u001B[48;2;30;19;38mprompt\u001B[0m') + expect(theme.bgUser('prompt')).toBe('\u001B[48;2;21;26;48mprompt\u001B[0m') expect(theme.bgTool('exec')).toBe('\u001B[48;2;14;18;24mexec\u001B[0m') - expect(theme.bgThinking('thought')).toBe('\u001B[48;2;32;28;44mthought\u001B[0m') + expect(theme.bgThinking('thought')).toBe('\u001B[48;2;26;29;46mthought\u001B[0m') expect(theme.bgError('failure')).toBe('\u001B[48;2;45;15;25mfailure\u001B[0m') expect(theme.bgCode('const x = 1')).toBe('\u001B[48;2;15;18;24mconst x = 1\u001B[0m') expect(theme.bgMeta('plan')).toBe('\u001B[48;2;18;20;26mplan\u001B[0m') @@ -64,7 +80,7 @@ describe('createTheme', () => { it('renders grok background colors under 256-color palette', () => { const theme = createTheme(true, { TERM: 'xterm-256color' }) - expect(theme.bgUser('prompt')).toBe('\u001B[48;5;53mprompt\u001B[0m') + expect(theme.bgUser('prompt')).toBe('\u001B[48;5;17mprompt\u001B[0m') expect(theme.bgTool('exec')).toBe('\u001B[48;5;235mexec\u001B[0m') expect(theme.bgThinking('thought')).toBe('\u001B[48;5;237mthought\u001B[0m') expect(theme.bgError('failure')).toBe('\u001B[48;5;52mfailure\u001B[0m') @@ -75,14 +91,14 @@ describe('createTheme', () => { it('swaps grok background colors for light palette and back', () => { const theme = createTheme(true, { COLORTERM: 'truecolor' }) theme.setLight(true) - expect(theme.bgUser('prompt')).toBe('\u001B[48;2;243;234;246mprompt\u001B[0m') + expect(theme.bgUser('prompt')).toBe('\u001B[48;2;238;241;251mprompt\u001B[0m') expect(theme.bgTool('exec')).toBe('\u001B[48;2;243;245;248mexec\u001B[0m') - expect(theme.bgThinking('thought')).toBe('\u001B[48;2;246;243;252mthought\u001B[0m') + expect(theme.bgThinking('thought')).toBe('\u001B[48;2;243;245;253mthought\u001B[0m') expect(theme.bgError('failure')).toBe('\u001B[48;2;254;226;226mfailure\u001B[0m') expect(theme.diffAdd('+ line')).toBe('\u001B[48;2;236;253;245;38;2;22;101;52m+ line\u001B[0m') expect(theme.diffDel('- line')).toBe('\u001B[48;2;254;242;242;38;2;153;27;27m- line\u001B[0m') theme.setLight(false) - expect(theme.bgUser('prompt')).toBe('\u001B[48;2;30;19;38mprompt\u001B[0m') + expect(theme.bgUser('prompt')).toBe('\u001B[48;2;21;26;48mprompt\u001B[0m') }) it('leaves grok backgrounds unstyled off-TTY or under NO_COLOR', () => { @@ -96,6 +112,101 @@ describe('createTheme', () => { }) }) +describe('the theme catalog', () => { + const env256 = { TERM: 'xterm-256color' } + + it('keeps the navy band and the thinking fill by override, not by the nearest index', () => { + // The gray ramp is nearer to the navy than any cube colour, and would lose its hue. + expect(nearest256(21, 26, 48)).toBe(234) + expect(createTheme(true, env256).sgr('bg_light')).toBe('\u001B[48;5;17m') + // The nearest to the thinking fill is 235, the tool fill's index. + expect(nearest256(26, 29, 46)).toBe(235) + expect(createTheme(true, env256).sgr('bg_thinking')).toBe('\u001B[48;5;237m') + }) + + it('quantizes the brand to the palette index and the ANSI colour closest to it', () => { + expect(nearest256(77, 107, 254)).toBe(63) + expect(nearestAnsi(77, 107, 254)).toBe('94') + expect(params({ rgb: [77, 107, 254] }, 'fg', '256')).toBe('38;5;63') + expect(params({ rgb: [77, 107, 254] }, 'bg', '16')).toBe('104') + expect(params({ x256: 236 }, 'bg', '16')).toBe('48;5;236') + expect(params(undefined, 'fg', 'truecolor')).toBe('') + }) + + it('follows the background under auto and ignores it for a theme chosen by name', () => { + const auto = createTheme(true, env256) + expect(auto.setting).toBe('auto') + expect(auto.resolved).toBe('deepseek') + expect(auto.setLight(true)).toBe(true) + expect(auto.resolved).toBe('deepseek-light') + expect(auto.setLight(true)).toBe(false) + expect(auto.agent('x')).toBe('\u001B[38;5;62mx\u001B[0m') + expect(auto.accent('x')).toBe('\u001B[38;5;63mx\u001B[0m') + + const dark = createTheme(true, env256, 'deepseek') + expect(dark.setLight(true)).toBe(false) + expect(dark.resolved).toBe('deepseek') + expect(dark.dim('x')).toBe('\u001B[38;5;245mx\u001B[0m') + }) + + it('switches in place, so roles already handed out paint the new palette', () => { + const theme = createTheme(true, env256) + const { accent, bgUser } = theme + const { keyword } = theme.syntax + expect(theme.setTheme('deepseek-light')).toBe(true) + expect(theme.setting).toBe('deepseek-light') + expect(bgUser('x')).toBe('\u001B[48;5;189mx\u001B[0m') + expect(theme.setTheme('terminal')).toBe(true) + expect(accent('x')).toBe('\u001B[34mx\u001B[0m') + expect(keyword('x')).toBe('\u001B[35mx\u001B[0m') + // Naming the palette already painting changes nothing that needs repainting. + expect(createTheme(true, env256).setTheme('deepseek')).toBe(false) + }) + + it('paints the terminal theme in the sixteen ANSI colours at every depth, with no surfaces', () => { + for (const env of [{}, env256, { COLORTERM: 'truecolor' }]) { + const theme = createTheme(true, env, 'terminal') + expect(theme.bands).toBe(false) + expect(theme.cursor).toBeUndefined() + expect(theme.accent('x')).toBe('\u001B[34mx\u001B[0m') + expect(theme.agent('x')).toBe('\u001B[36mx\u001B[0m') + expect(theme.dim('x')).toBe('\u001B[90mx\u001B[0m') + expect(theme.muted('x')).toBe('\u001B[90mx\u001B[0m') + expect(theme.bgUser('x')).toBe('\u001B[1mx\u001B[0m') + expect(theme.bgTool('x')).toBe('x') + expect(theme.bgThinking('x')).toBe('x') + expect(theme.bgError('x')).toBe('x') + expect(theme.bgCode('x')).toBe('x') + expect(theme.diffAdd('+ x')).toBe('\u001B[32m+ x\u001B[0m') + expect(theme.diffDel('- x')).toBe('\u001B[31m- x\u001B[0m') + expect(theme.sgr('bg_hover')).toBe('') + expect(theme.color('#ff8800')?.('x')).toBe('\u001B[33mx\u001B[0m') + } + }) + + it('colours the cursor with the brand in both DeepSeek themes, at every depth', () => { + for (const env of [{}, env256, { COLORTERM: 'truecolor' }]) { + expect(createTheme(true, env).cursor).toBe('#4d6bfe') + expect(createTheme(true, env, 'deepseek-light').cursor).toBe('#4d6bfe') + } + expect(createTheme(true, {}).bands).toBe(true) + }) + + it('paints nothing and switches nothing under NO_COLOR or off a TTY', () => { + for (const theme of [createTheme(true, { NO_COLOR: '1' }, 'deepseek'), createTheme(false, {})]) { + expect(theme.setTheme('terminal')).toBe(false) + expect(theme.setLight(true)).toBe(false) + expect(theme.resolved).toBeUndefined() + expect(theme.cursor).toBeUndefined() + expect(theme.bands).toBe(false) + expect(theme.sgr('accent')).toBe('') + expect(theme.sgr('bg_hover')).toBe('') + expect(theme.heading('x')).toBe('x') + expect(theme.link('x')).toBe('x') + } + }) +}) + describe('a colour an answer names', () => { it('reads names, hex, and rgb() the way a browser would', () => { expect(parseColor('green')).toEqual({ ansi: '32' }) diff --git a/packages/bundle/tests/todos.spec.ts b/packages/bundle/tests/todos.spec.ts index 1bd1dba8..0fee9c87 100644 --- a/packages/bundle/tests/todos.spec.ts +++ b/packages/bundle/tests/todos.spec.ts @@ -89,7 +89,9 @@ describe('todoRow', () => { const row = todoRow(list('in_progress:write the fix'), coloured, 80) ?? '' expect(row).toContain('\u001B[1m▶') expect(row).not.toContain('\u001B[93m▶') - expect(row).not.toContain('\u001B[36m▶') + const [accentOpen = ''] = coloured.accent('x').split('x') + expect(accentOpen).not.toBe('') + expect(row).not.toContain(`${accentOpen}▶`) }) }) diff --git a/packages/bundle/tests/transcript.spec.ts b/packages/bundle/tests/transcript.spec.ts index e5e5b58e..29a6d9f1 100644 --- a/packages/bundle/tests/transcript.spec.ts +++ b/packages/bundle/tests/transcript.spec.ts @@ -1456,6 +1456,27 @@ describe('transcript density', () => { }) }) +describe('the person\'s message under a theme that paints no bands', () => { + it('sets the prompt in bold with no pad rows, where the DeepSeek themes band it', () => { + const userEvent = { + type: 'user/message', + seq: 1, + time: 0, + data: { role: 'user', content: [{ type: 'text', text: 'my prompt' }], source: { kind: 'user' } }, + } as unknown as SessionEvent + const terminal = createTheme(true, { COLORTERM: 'truecolor' }, 'terminal') + const plainRows = new Transcript({ theme: terminal, columns: 80, cwd: CWD }, { call: () => undefined, result: () => undefined }) + const lines = plainRows.render(userEvent) + expect(lines[0]).toBe('\u001B[1m my prompt\u001B[0m') + expect(lines[0]).not.toMatch(/\u001B\[48;/u) + expect(plainRows.takePromptPad()).toBeUndefined() + + const banded = new Transcript({ theme: createTheme(true, { COLORTERM: 'truecolor' }), columns: 80, cwd: CWD }, { call: () => undefined, result: () => undefined }) + expect(banded.render(userEvent)[0]).toBe('\u001B[48;2;21;26;48m my prompt\u001B[0m') + expect(banded.takePromptPad()).toBe('\u001B[48;2;21;26;48m \u001B[0m') + }) +}) + describe('grok background differentiation across functional blocks', () => { it('styles user, thinking, and error blocks, and leaves tool cards unhighlighted', () => { const colorTheme = createTheme(true, { COLORTERM: 'truecolor' }) diff --git a/site/assets/favicon.svg b/site/assets/favicon.svg index ffb9e61e..53823b7c 100644 --- a/site/assets/favicon.svg +++ b/site/assets/favicon.svg @@ -2,6 +2,6 @@ <rect x="20" y="20" width="200" height="200" rx="44" fill="#10173a"></rect> <path d="M96,52 L142,86 L96,120" fill="none" stroke="#7e96f5" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"></path> <path d="M82,132 L158,132 L144,152 L96,152 Z" fill="#eef1fb"></path> - <path d="M56,166 Q72,158 88,166 T120,166 T152,166 T184,166" fill="none" stroke="#3d56d6" stroke-width="5" stroke-linecap="round"></path> - <path fill="#3d56d6" d="M120,206 C112,192 100,185 86,187 C97,174 111,175 120,184 C129,175 143,174 154,187 C140,185 128,192 120,206 Z"></path> + <path d="M56,166 Q72,158 88,166 T120,166 T152,166 T184,166" fill="none" stroke="#4d6bfe" stroke-width="5" stroke-linecap="round"></path> + <path fill="#4d6bfe" d="M120,206 C112,192 100,185 86,187 C97,174 111,175 120,184 C129,175 143,174 154,187 C140,185 128,192 120,206 Z"></path> </svg> \ No newline at end of file diff --git a/site/assets/logo.svg b/site/assets/logo.svg index ffb9e61e..53823b7c 100644 --- a/site/assets/logo.svg +++ b/site/assets/logo.svg @@ -2,6 +2,6 @@ <rect x="20" y="20" width="200" height="200" rx="44" fill="#10173a"></rect> <path d="M96,52 L142,86 L96,120" fill="none" stroke="#7e96f5" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"></path> <path d="M82,132 L158,132 L144,152 L96,152 Z" fill="#eef1fb"></path> - <path d="M56,166 Q72,158 88,166 T120,166 T152,166 T184,166" fill="none" stroke="#3d56d6" stroke-width="5" stroke-linecap="round"></path> - <path fill="#3d56d6" d="M120,206 C112,192 100,185 86,187 C97,174 111,175 120,184 C129,175 143,174 154,187 C140,185 128,192 120,206 Z"></path> + <path d="M56,166 Q72,158 88,166 T120,166 T152,166 T184,166" fill="none" stroke="#4d6bfe" stroke-width="5" stroke-linecap="round"></path> + <path fill="#4d6bfe" d="M120,206 C112,192 100,185 86,187 C97,174 111,175 120,184 C129,175 143,174 154,187 C140,185 128,192 120,206 Z"></path> </svg> \ No newline at end of file diff --git a/site/data/screens.json b/site/data/screens.json index aa5ca3cf..04d94d3d 100644 --- a/site/data/screens.json +++ b/site/data/screens.json @@ -7,10 +7,6 @@ "title": "A session that is its own space", "note": "The alternate screen, the input box pinned to the bottom, and the status row that is always current.", "keep": [ - [ - 3, - 12 - ], [ 30, 33 @@ -23,7 +19,11 @@ "pen": "" }, { - "text": "installed preset into /var/folders/y1/dnl_x6zd03v5f5wzbndy44tm0000gn/T/codsh-e2e-", + "text": "│ ", + "pen": "90" + }, + { + "text": "installed preset into /var/folders/s6/7t1hr_t554l4nqkfxkpg8q2w0000gn/T/codsh-e2", "pen": "38;5;245" } ], @@ -33,14 +33,35 @@ "pen": "" }, { - "text": "home-Zxsd9b/.agent-presets/code-cli", + "text": "│ ", + "pen": "90" + }, + { + "text": "e-home-6dk0Zg/.agent-presets/code-cli", "pen": "38;5;245" } ], - [], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│", + "pen": "90" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -50,7 +71,15 @@ ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -68,7 +97,15 @@ ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -78,7 +115,15 @@ ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -88,22 +133,38 @@ ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { "text": "█▄██▄▄██▄██▄", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" } ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { "text": "▀▄▄", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -111,7 +172,7 @@ }, { "text": "██", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -119,28 +180,49 @@ }, { "text": "▄▄▀", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" } ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { "text": "▀█▄▄█▀", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│", + "pen": "90" } ], - [], [ { "text": " ", "pen": "" }, + { + "text": "│ ", + "pen": "90" + }, { "text": "✻", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " Welcome to codsh · cli-mock · code-cli", @@ -153,11 +235,20 @@ "pen": "" }, { - "text": "/help · /status · Tab · ⇧Tab plan · Ctrl-C · /exit", + "text": "│ /help · /status · Tab · ⇧Tab plan · Ctrl-C · /exit", + "pen": "90" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│", "pen": "90" } ], - [], [], [], [], @@ -181,7 +272,7 @@ }, { "text": "╭───────────────────────────────────────────────────────────────────────────────╮", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -191,7 +282,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -199,7 +290,7 @@ }, { "text": "›", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -215,7 +306,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -225,7 +316,7 @@ }, { "text": "╰───────────────────────────────────────────────────────────────────────────────╯", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -245,10 +336,6 @@ "title": "The question you just asked holds the top", "note": "A submitted prompt takes the viewport top and the reply fills the space beneath it. Read back into history and the way home is the same frame: the wheel and PgDn land on it again.", "keep": [ - [ - 3, - 25 - ], [ 29, 33 @@ -261,7 +348,11 @@ "pen": "" }, { - "text": "installed preset into /var/folders/y1/dnl_x6zd03v5f5wzbndy44tm0000gn/T/codsh-e2e-", + "text": "│ ", + "pen": "90" + }, + { + "text": "installed preset into /var/folders/s6/7t1hr_t554l4nqkfxkpg8q2w0000gn/T/codsh-e2", "pen": "38;5;245" } ], @@ -271,14 +362,35 @@ "pen": "" }, { - "text": "home-dMqK3F/.agent-presets/code-cli", + "text": "│ ", + "pen": "90" + }, + { + "text": "e-home-PmO9Ie/.agent-presets/code-cli", "pen": "38;5;245" } ], - [], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│", + "pen": "90" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -288,7 +400,15 @@ ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -306,7 +426,15 @@ ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -316,7 +444,15 @@ ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -326,22 +462,38 @@ ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { "text": "█▄██▄▄██▄██▄", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" } ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { "text": "▀▄▄", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -349,7 +501,7 @@ }, { "text": "██", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -357,28 +509,49 @@ }, { "text": "▄▄▀", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" } ], [ { - "text": " ", + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { "text": "▀█▄▄█▀", - "pen": "38;2;61;86;214" + "pen": "38;2;77;107;254" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│", + "pen": "90" } ], - [], [ { "text": " ", "pen": "" }, + { + "text": "│ ", + "pen": "90" + }, { "text": "✻", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " Welcome to codsh · cli-mock · code-cli", @@ -391,23 +564,32 @@ "pen": "" }, { - "text": "/help · /status · Tab · ⇧Tab plan · Ctrl-C · /exit", + "text": "│ /help · /status · Tab · ⇧Tab plan · Ctrl-C · /exit", + "pen": "90" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│", "pen": "90" } ], - [], [ { "text": " ", "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -416,12 +598,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " where does the retry live? ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -430,22 +612,26 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ { - "text": " ", + "text": " ", "pen": "" }, { - "text": " ", - "pen": "48;5;236" + "text": "│ ", + "pen": "38;2;126;150;245" + }, + { + "text": " thought for 0.0s · total 0.0s", + "pen": "38;5;245" } ], [ @@ -454,60 +640,64 @@ "pen": "" }, { - "text": "✻ ", - "pen": "35" + "text": "│ ", + "pen": "90" }, { - "text": " thought for 0.1s · total 0.1s ", - "pen": "38;5;245;48;5;236" + "text": "CODE_CLI_ANSWER after thinking (turn 2)", + "pen": "" } ], [ { - "text": " ", + "text": " ", "pen": "" }, { - "text": " ", - "pen": "48;5;236" + "text": "│", + "pen": "90" } ], - [], [ { - "text": " CODE_CLI_ANSWER after thinking (turn 2)", + "text": " ", "pen": "" + }, + { + "text": "│ 0.1s (thought 0.0s) · 14 tokens", + "pen": "38;5;245" } ], - [], [ { "text": " ", "pen": "" }, { - "text": " 0.1s (thought 0.1s) · 14 tokens", + "text": "│", "pen": "38;5;245" } ], - [], [ { "text": " ", "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [], [], [], + [], + [], + [], [ { "text": " ", @@ -515,7 +705,7 @@ }, { "text": "╭───────────────────────────────────────────────────────────────────────────────╮", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -525,7 +715,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -533,7 +723,7 @@ }, { "text": "›", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -549,7 +739,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -559,7 +749,7 @@ }, { "text": "╰───────────────────────────────────────────────────────────────────────────────╯", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -599,10 +789,6 @@ "keep": [ [ 0, - 19 - ], - [ - 21, 33 ] ], @@ -613,12 +799,8 @@ "pen": "" }, { - "text": "› ", - "pen": "36;48;5;53" - }, - { - "text": " explain the render path ", - "pen": "48;5;53" + "text": " ", + "pen": "48;2;21;26;48" }, { "text": "↑", @@ -627,12 +809,20 @@ ], [ { - "text": " ", + "text": " ", "pen": "" }, { - "text": "●", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254;48;2;21;26;48" + }, + { + "text": " now the input path ", + "pen": "48;2;21;26;48" + }, + { + "text": "·", + "pen": "2" } ], [ @@ -641,15 +831,25 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": " ", + "pen": "48;2;21;26;48" }, { - "text": " ", - "pen": "48;5;53" + "text": "●", + "pen": "38;2;77;107;254" + } + ], + [ + { + "text": " ", + "pen": "" }, { - "text": "·", + "text": "─────────────────────────────────────────────────────────────────────────────────", + "pen": "90" + }, + { + "text": "↓", "pen": "2" } ], @@ -659,15 +859,15 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "90" }, { - "text": " now the input path ", - "pen": "48;5;53" + "text": "•", + "pen": "38;5;245" }, { - "text": "↓", + "text": " third bullet keeps the answer long", "pen": "" } ], @@ -677,104 +877,139 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "90" }, { - "text": " ", - "pen": "48;5;53" + "text": "•", + "pen": "38;5;245" + }, + { + "text": " fourth bullet keeps the answer long", + "pen": "" } ], - [], [ { "text": " ", "pen": "" }, { - "text": "CODE_CLI_HEADING", - "pen": "38;5;172" + "text": "│ ", + "pen": "90" + }, + { + "text": "•", + "pen": "38;5;245" + }, + { + "text": " fifth bullet keeps the answer long", + "pen": "" } ], - [], [ { - "text": " Prose with ", + "text": " ", "pen": "" }, { - "text": "bold", - "pen": "1" + "text": "│ ", + "pen": "90" }, { - "text": ", ", - "pen": "" + "text": "•", + "pen": "38;5;245" }, { - "text": "em", - "pen": "1" - }, + "text": " sixth bullet keeps the answer long", + "pen": "" + } + ], + [ { - "text": ", ", + "text": " ", "pen": "" }, { - "text": "inline_code", - "pen": "38;5;172" + "text": "│ ", + "pen": "90" }, { - "text": ", and a ", - "pen": "" + "text": "•", + "pen": "38;5;245" }, { - "text": "link", - "pen": "1" - }, + "text": " seventh bullet keeps the answer long", + "pen": "" + } + ], + [ { - "text": " ", + "text": " ", "pen": "" }, { - "text": "(https://x.dev)", + "text": "│ ", + "pen": "90" + }, + { + "text": "•", "pen": "38;5;245" }, { - "text": ".", + "text": " eighth bullet: past the fold threshold at any test width", "pen": "" } ], [ { - "text": " An identifier like some_helper_name must survive intact.", + "text": " ", "pen": "" + }, + { + "text": "│", + "pen": "90" } ], [ { - "text": " Gain: ", + "text": " ", "pen": "" }, { - "text": "CODE_CLI_GAIN", - "pen": "32" + "text": "│ ", + "pen": "90" }, { - "text": " & ", + "text": "╭────────┬─────────────────────────────────────────────────────────────────────", + "pen": "38;5;245" + } + ], + [ + { + "text": " ", "pen": "" }, { - "text": "held", - "pen": "1" + "text": "│ ", + "pen": "90" + }, + { + "text": "─╮", + "pen": "38;5;245" } ], - [], [ { "text": " ", "pen": "" }, { - "text": "•", + "text": "│ ", + "pen": "90" + }, + { + "text": "│", "pen": "38;5;245" }, { @@ -782,26 +1017,24 @@ "pen": "" }, { - "text": "screen.ts", - "pen": "38;5;172;1" + "text": "维度", + "pen": "1" }, { - "text": ": the viewport module", - "pen": "" - } - ], - [ - { - "text": " ", + "text": " ", "pen": "" }, { - "text": "•", + "text": "│", "pen": "38;5;245" }, { - "text": " second bullet", + "text": " ", "pen": "" + }, + { + "text": "内容", + "pen": "1" } ], [ @@ -810,12 +1043,16 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " third bullet keeps the answer long", + "text": " ", "pen": "" + }, + { + "text": "│", + "pen": "38;5;245" } ], [ @@ -824,12 +1061,12 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " fourth bullet keeps the answer long", - "pen": "" + "text": "├────────┼─────────────────────────────────────────────────────────────────────", + "pen": "38;5;245" } ], [ @@ -838,12 +1075,12 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " fifth bullet keeps the answer long", - "pen": "" + "text": "─┤", + "pen": "38;5;245" } ], [ @@ -852,25 +1089,23 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " sixth bullet keeps the answer long", - "pen": "" - } - ], - [ + "text": "│", + "pen": "38;5;245" + }, { - "text": " ", + "text": " 一句话 ", "pen": "" }, { - "text": "•", + "text": "│", "pen": "38;5;245" }, { - "text": " seventh bullet keeps the answer long", + "text": " 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格", "pen": "" } ], @@ -880,22 +1115,15 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " eighth bullet: past the fold threshold at any test width", - "pen": "" - } - ], - [], - [ - { - "text": " ", + "text": " ", "pen": "" }, { - "text": "╭────────┬──────────────────────────────────────────────────────────────────────╮", + "text": "│", "pen": "38;5;245" } ], @@ -905,19 +1133,15 @@ "pen": "" }, { - "text": "│", - "pen": "38;5;245" - }, - { - "text": " ", - "pen": "" + "text": "│ ", + "pen": "90" }, { - "text": "维度", - "pen": "1" + "text": "│", + "pen": "38;5;245" }, { - "text": " ", + "text": " ", "pen": "" }, { @@ -925,15 +1149,21 @@ "pen": "38;5;245" }, { - "text": " ", + "text": " 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必", + "pen": "" + } + ], + [ + { + "text": " ", "pen": "" }, { - "text": "内容", - "pen": "1" + "text": "│ ", + "pen": "90" }, { - "text": " ", + "text": " ", "pen": "" }, { @@ -947,13 +1177,15 @@ "pen": "" }, { - "text": "├────────┼──────────────────────────────────────────────────────────────────────┤", + "text": "│ ", + "pen": "90" + }, + { + "text": "│", "pen": "38;5;245" - } - ], - [ + }, { - "text": " ", + "text": " ", "pen": "" }, { @@ -961,15 +1193,21 @@ "pen": "38;5;245" }, { - "text": " 一句话 ", + "text": " 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端", + "pen": "" + } + ], + [ + { + "text": " ", "pen": "" }, { - "text": "│", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格 ", + "text": " ", "pen": "" }, { @@ -982,6 +1220,10 @@ "text": " ", "pen": "" }, + { + "text": "│ ", + "pen": "90" + }, { "text": "│", "pen": "38;5;245" @@ -995,12 +1237,8 @@ "pen": "38;5;245" }, { - "text": " 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必 ", + "text": " 宽度下都必须在单元格内部换行。", "pen": "" - }, - { - "text": "│", - "pen": "38;5;245" } ], [ @@ -1009,23 +1247,43 @@ "pen": "" }, { - "text": "│", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " ", + "text": " ", "pen": "" }, { "text": "│", "pen": "38;5;245" + } + ], + [ + { + "text": " ", + "pen": "" }, { - "text": " 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端 ", + "text": "│ ", + "pen": "90" + }, + { + "text": "├────────┼─────────────────────────────────────────────────────────────────────", + "pen": "38;5;245" + } + ], + [ + { + "text": " ", "pen": "" }, { - "text": "│", + "text": "│ ", + "pen": "90" + }, + { + "text": "─┤", "pen": "38;5;245" } ], @@ -1034,12 +1292,16 @@ "text": " ", "pen": "" }, + { + "text": "│ ", + "pen": "90" + }, { "text": "│", "pen": "38;5;245" }, { - "text": " ", + "text": " 命令 ", "pen": "" }, { @@ -1047,12 +1309,12 @@ "pen": "38;5;245" }, { - "text": " 宽度下都必须在单元格内部换行。 ", + "text": " ", "pen": "" }, { - "text": "│", - "pen": "38;5;245" + "text": "codsh", + "pen": "38;5;172" } ], [ @@ -1061,7 +1323,15 @@ "pen": "" }, { - "text": "├────────┼──────────────────────────────────────────────────────────────────────┤", + "text": "│ ", + "pen": "90" + }, + { + "text": " ", + "pen": "" + }, + { + "text": "│", "pen": "38;5;245" } ], @@ -1082,7 +1352,7 @@ }, { "text": "╭───────────────────────────────────────────────────────────────────────────────╮", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1092,7 +1362,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -1100,7 +1370,7 @@ }, { "text": "›", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -1116,7 +1386,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1126,7 +1396,7 @@ }, { "text": "╰───────────────────────────────────────────────────────────────────────────────╯", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1162,12 +1432,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -1176,12 +1446,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " create the note ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -1190,12 +1460,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -1204,12 +1474,8 @@ "pen": "" }, { - "text": "│ ", - "pen": "38;5;172" - }, - { - "text": " ", - "pen": "48;5;235" + "text": "│", + "pen": "38;5;245" } ], [ @@ -1218,40 +1484,34 @@ "pen": "" }, { - "text": "│ ", - "pen": "38;5;172" - }, - { - "text": " ", - "pen": "48;5;235" - }, - { - "text": "●", - "pen": "32;48;5;235" + "text": "│ ●", + "pen": "38;5;245" }, { - "text": " ", - "pen": "48;5;235" + "text": " Write note.txt ", + "pen": "" }, { - "text": "Write note.txt", - "pen": "38;5;172;48;5;235" + "text": "+1 -0", + "pen": "90" }, { "text": " ", - "pen": "48;5;235" + "pen": "" }, { - "text": "+1 -0", - "pen": "90;48;5;235" - }, + "text": "✔", + "pen": "32" + } + ], + [ { - "text": " ", - "pen": "48;5;235" + "text": " ", + "pen": "" }, { - "text": "✔ ", - "pen": "32;48;5;235" + "text": "│", + "pen": "90" } ], [ @@ -1261,18 +1521,21 @@ }, { "text": "│ ", - "pen": "38;5;172" + "pen": "90" }, { - "text": " ", - "pen": "48;5;235" + "text": "CODE_CLI_CALL_OK via cli-mock", + "pen": "" } ], - [], [ { - "text": " CODE_CLI_CALL_OK via cli-mock", + "text": " ", "pen": "" + }, + { + "text": "│", + "pen": "90" } ], [], @@ -1303,7 +1566,7 @@ }, { "text": "╭───────────────────────────────────────────────────────────────────────────────╮", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1313,7 +1576,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -1321,7 +1584,7 @@ }, { "text": "›", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -1337,7 +1600,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1347,7 +1610,7 @@ }, { "text": "╰───────────────────────────────────────────────────────────────────────────────╯", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1356,7 +1619,7 @@ "pen": "" }, { - "text": "⠙", + "text": "⠋", "pen": "38;5;172" }, { @@ -1382,12 +1645,12 @@ }, { "id": "fold-open", - "title": "Thinking, open while you read it", - "note": "A thought streams into the transcript under a head that becomes its clock, and stays open until the next prompt folds it. A click folds it now, a click on the clock opens it again, and Ctrl+O swaps every block at once.", + "title": "Thinking, folded until you open it", + "note": "A thought streams as a ticking `thinking…` head that becomes its clock, folded, with the deliberation behind a click or Ctrl+O. A click on the clock opens it, a click inside folds it, and Ctrl+O swaps every block at once.", "keep": [ [ 0, - 10 + 5 ], [ 29, @@ -1401,12 +1664,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -1415,12 +1678,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " think it over ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -1429,22 +1692,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" - }, - { - "text": " ", - "pen": "48;5;53" - } - ], - [ - { - "text": " ", - "pen": "" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;236" + "pen": "48;2;21;26;48" } ], [ @@ -1453,61 +1706,43 @@ "pen": "" }, { - "text": "✻ ", - "pen": "35" + "text": "│ ", + "pen": "38;2;126;150;245" }, { - "text": " thought for 0.1s ", - "pen": "38;5;245;48;5;236" + "text": " thought for 0.0s", + "pen": "38;5;245" } ], [ { - "text": " ", + "text": " ", "pen": "" }, { - "text": " ", - "pen": "48;5;236" - } - ], - [ - { - "text": " ", - "pen": "" + "text": "│ ", + "pen": "90" }, { - "text": " CODE_CLI_THINKING about the request ", - "pen": "38;5;245;48;5;236" - } - ], - [ - { - "text": " ", + "text": "CODE_CLI_ANSWER after thinking (turn 2)", "pen": "" - }, - { - "text": " weighing the options carefully ", - "pen": "38;5;245;48;5;236" } ], [ { - "text": " ", + "text": " ", "pen": "" }, { - "text": " ", - "pen": "48;5;236" + "text": "│", + "pen": "90" } ], [], - [ - { - "text": " CODE_CLI_ANSWER after thinking (turn 2)", - "pen": "" - } - ], + [], + [], + [], + [], [], [], [], @@ -1533,7 +1768,7 @@ }, { "text": "╭───────────────────────────────────────────────────────────────────────────────╮", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1543,7 +1778,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -1551,7 +1786,7 @@ }, { "text": "›", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -1567,7 +1802,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1577,7 +1812,7 @@ }, { "text": "╰───────────────────────────────────────────────────────────────────────────────╯", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1586,7 +1821,7 @@ "pen": "" }, { - "text": "⠙", + "text": "⠋", "pen": "38;5;172" }, { @@ -1594,7 +1829,7 @@ "pen": "" }, { - "text": "0.1s · Ctrl-C to interrupt", + "text": "0.0s · Ctrl-C to interrupt", "pen": "38;5;245" } ], @@ -1617,7 +1852,7 @@ "keep": [ [ 0, - 13 + 14 ], [ 26, @@ -1631,12 +1866,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -1645,12 +1880,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " plan the work ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -1659,12 +1894,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36" + "text": "│ ", + "pen": "38;2;77;107;254" }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -1673,12 +1908,8 @@ "pen": "" }, { - "text": "│ ", - "pen": "38;5;172" - }, - { - "text": " ", - "pen": "48;5;235" + "text": "│", + "pen": "38;5;245" } ], [ @@ -1687,40 +1918,24 @@ "pen": "" }, { - "text": "│ ", - "pen": "38;5;172" - }, - { - "text": " ", - "pen": "48;5;235" - }, - { - "text": "●", - "pen": "32;48;5;235" - }, - { - "text": " ", - "pen": "48;5;235" - }, - { - "text": "Update todo list", - "pen": "38;5;172;48;5;235" + "text": "│ ●", + "pen": "38;5;245" }, { - "text": " ", - "pen": "48;5;235" + "text": " Update todo list ", + "pen": "" }, { "text": "· 1 line", - "pen": "38;5;245;48;5;235" + "pen": "38;5;245" }, { "text": " ", - "pen": "48;5;235" + "pen": "" }, { - "text": "✔ ", - "pen": "32;48;5;235" + "text": "✔", + "pen": "32" } ], [ @@ -1729,12 +1944,8 @@ "pen": "" }, { - "text": "│ ", - "pen": "38;5;172" - }, - { - "text": " ", - "pen": "48;5;235" + "text": "│", + "pen": "38;5;245" } ], [ @@ -1744,19 +1955,19 @@ }, { "text": "│ ", - "pen": "38;5;172" + "pen": "38;5;245" }, { "text": "todos", - "pen": "38;5;172;48;5;235" + "pen": "38;5;172" }, { "text": " ", - "pen": "48;5;235" + "pen": "" }, { - "text": "1/3 · 1 in progress · 1 open ", - "pen": "38;5;245;48;5;235" + "text": "1/3 · 1 in progress · 1 open", + "pen": "38;5;245" } ], [ @@ -1766,23 +1977,23 @@ }, { "text": "│ ", - "pen": "38;5;172" + "pen": "38;5;245" }, { "text": " ", - "pen": "48;5;235" + "pen": "" }, { "text": "✔", - "pen": "32;48;5;235" + "pen": "32" }, { "text": " ", - "pen": "48;5;235" + "pen": "" }, { - "text": "read the code ", - "pen": "38;5;245;48;5;235" + "text": "read the code", + "pen": "38;5;245" } ], [ @@ -1792,19 +2003,19 @@ }, { "text": "│ ", - "pen": "38;5;172" + "pen": "38;5;245" }, { "text": " ", - "pen": "48;5;235" + "pen": "" }, { "text": "▶", - "pen": "38;5;172;48;5;235" + "pen": "38;5;172" }, { - "text": " write the fix ", - "pen": "48;5;235" + "text": " write the fix", + "pen": "" } ], [ @@ -1814,36 +2025,72 @@ }, { "text": "│ ", - "pen": "38;5;172" + "pen": "38;5;245" }, { "text": " ", - "pen": "48;5;235" + "pen": "" }, { "text": "○", - "pen": "38;5;245;48;5;235" + "pen": "38;5;245" + }, + { + "text": " run the tests", + "pen": "" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│", + "pen": "38;5;245" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": "CODE_CLI_CALL_OK via cli-mock", + "pen": "" + } + ], + [ + { + "text": " ", + "pen": "" }, { - "text": " run the tests ", - "pen": "48;5;235" + "text": "│", + "pen": "90" } ], - [], [ { - "text": " CODE_CLI_CALL_OK via cli-mock", + "text": " ", "pen": "" + }, + { + "text": "│ 0.1s · 26 tokens", + "pen": "38;5;245" } ], - [], [ { "text": " ", "pen": "" }, { - "text": " 0.1s · 26 tokens", + "text": "│", "pen": "38;5;245" } ], @@ -1858,7 +2105,6 @@ [], [], [], - [], [ { "text": " ", @@ -1866,7 +2112,7 @@ }, { "text": "╭───────────────────────────────────────────────────────────────────────────────╮", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1876,7 +2122,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -1884,7 +2130,7 @@ }, { "text": "›", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -1900,7 +2146,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1910,7 +2156,7 @@ }, { "text": "╰───────────────────────────────────────────────────────────────────────────────╯", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -1996,10 +2242,6 @@ "keep": [ [ 1, - 10 - ], - [ - 12, 33 ] ], @@ -2011,7 +2253,7 @@ }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -2020,12 +2262,12 @@ "pen": "" }, { - "text": "› ", - "pen": "36;48;5;53" + "text": "│ ", + "pen": "38;2;77;107;254;48;2;21;26;48" }, { "text": " explain it ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -2035,7 +2277,7 @@ }, { "text": " ", - "pen": "48;5;53" + "pen": "48;2;21;26;48" } ], [ @@ -2054,26 +2296,36 @@ "pen": "" }, { - "text": "•", + "text": "│ ", + "pen": "90" + }, + { + "text": "│", "pen": "38;5;245" }, { - "text": " second bullet", + "text": " ", "pen": "" - } - ], - [ + }, { - "text": " ", + "text": "维度", + "pen": "1" + }, + { + "text": " ", "pen": "" }, { - "text": "•", + "text": "│", "pen": "38;5;245" }, { - "text": " third bullet keeps the answer long", + "text": " ", "pen": "" + }, + { + "text": "内容", + "pen": "1" } ], [ @@ -2082,12 +2334,16 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " fourth bullet keeps the answer long", + "text": " ", "pen": "" + }, + { + "text": "│", + "pen": "38;5;245" } ], [ @@ -2096,12 +2352,12 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " fifth bullet keeps the answer long", - "pen": "" + "text": "├────────┼─────────────────────────────────────────────────────────────────────", + "pen": "38;5;245" } ], [ @@ -2110,12 +2366,12 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " sixth bullet keeps the answer long", - "pen": "" + "text": "─┤", + "pen": "38;5;245" } ], [ @@ -2124,36 +2380,41 @@ "pen": "" }, { - "text": "•", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " seventh bullet keeps the answer long", - "pen": "" - } - ], - [ + "text": "│", + "pen": "38;5;245" + }, { - "text": " ", + "text": " 一句话 ", "pen": "" }, { - "text": "•", + "text": "│", "pen": "38;5;245" }, { - "text": " eighth bullet: past the fold threshold at any test width", + "text": " 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格", "pen": "" } ], - [], [ { "text": " ", "pen": "" }, { - "text": "╭────────┬──────────────────────────────────────────────────────────────────────╮", + "text": "│ ", + "pen": "90" + }, + { + "text": " ", + "pen": "" + }, + { + "text": "│", "pen": "38;5;245" } ], @@ -2163,19 +2424,15 @@ "pen": "" }, { - "text": "│", - "pen": "38;5;245" - }, - { - "text": " ", - "pen": "" + "text": "│ ", + "pen": "90" }, { - "text": "维度", - "pen": "1" + "text": "│", + "pen": "38;5;245" }, { - "text": " ", + "text": " ", "pen": "" }, { @@ -2183,15 +2440,21 @@ "pen": "38;5;245" }, { - "text": " ", + "text": " 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必", + "pen": "" + } + ], + [ + { + "text": " ", "pen": "" }, { - "text": "内容", - "pen": "1" + "text": "│ ", + "pen": "90" }, { - "text": " ", + "text": " ", "pen": "" }, { @@ -2205,21 +2468,15 @@ "pen": "" }, { - "text": "├────────┼──────────────────────────────────────────────────────────────────────┤", - "pen": "38;5;245" - } - ], - [ - { - "text": " ", - "pen": "" + "text": "│ ", + "pen": "90" }, { "text": "│", "pen": "38;5;245" }, { - "text": " 一句话 ", + "text": " ", "pen": "" }, { @@ -2227,12 +2484,8 @@ "pen": "38;5;245" }, { - "text": " 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格 ", + "text": " 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端", "pen": "" - }, - { - "text": "│", - "pen": "38;5;245" } ], [ @@ -2241,19 +2494,11 @@ "pen": "" }, { - "text": "│", - "pen": "38;5;245" - }, - { - "text": " ", - "pen": "" - }, - { - "text": "│", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必 ", + "text": " ", "pen": "" }, { @@ -2266,6 +2511,10 @@ "text": " ", "pen": "" }, + { + "text": "│ ", + "pen": "90" + }, { "text": "│", "pen": "38;5;245" @@ -2279,12 +2528,8 @@ "pen": "38;5;245" }, { - "text": " 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端 ", + "text": " 宽度下都必须在单元格内部换行。", "pen": "" - }, - { - "text": "│", - "pen": "38;5;245" } ], [ @@ -2293,23 +2538,29 @@ "pen": "" }, { - "text": "│", - "pen": "38;5;245" + "text": "│ ", + "pen": "90" }, { - "text": " ", + "text": " ", "pen": "" }, { "text": "│", "pen": "38;5;245" - }, + } + ], + [ { - "text": " 宽度下都必须在单元格内部换行。 ", + "text": " ", "pen": "" }, { - "text": "│", + "text": "│ ", + "pen": "90" + }, + { + "text": "├────────┼─────────────────────────────────────────────────────────────────────", "pen": "38;5;245" } ], @@ -2319,7 +2570,11 @@ "pen": "" }, { - "text": "├────────┼──────────────────────────────────────────────────────────────────────┤", + "text": "│ ", + "pen": "90" + }, + { + "text": "─┤", "pen": "38;5;245" } ], @@ -2328,6 +2583,10 @@ "text": " ", "pen": "" }, + { + "text": "│ ", + "pen": "90" + }, { "text": "│", "pen": "38;5;245" @@ -2347,9 +2606,19 @@ { "text": "codsh", "pen": "38;5;172" + } + ], + [ + { + "text": " ", + "pen": "" }, { - "text": " ", + "text": "│ ", + "pen": "90" + }, + { + "text": " ", "pen": "" }, { @@ -2363,16 +2632,47 @@ "pen": "" }, { - "text": "╰────────┴──────────────────────────────────────────────────────────────────────╯", + "text": "│ ", + "pen": "90" + }, + { + "text": "╰────────┴─────────────────────────────────────────────────────────────────────", "pen": "38;5;245" } ], - [], [ { "text": " ", "pen": "" }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": "─╯", + "pen": "38;5;245" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│", + "pen": "90" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, { "text": "│", "pen": "38;5;245" @@ -2386,15 +2686,28 @@ "pen": "38;5;245" } ], - [], [ { "text": " ", "pen": "" }, { - "text": " ts ", - "pen": "90;48;5;235" + "text": "│", + "pen": "90" + } + ], + [ + { + "text": " ", + "pen": "" + }, + { + "text": "│ ", + "pen": "90" + }, + { + "text": " ts ", + "pen": "90;48;2;15;18;24" } ], [ @@ -2402,29 +2715,33 @@ "text": " ", "pen": "" }, + { + "text": "│ ", + "pen": "90" + }, { "text": " ", - "pen": "48;5;235" + "pen": "48;2;15;18;24" }, { "text": "const", - "pen": "38;5;176;48;5;235" + "pen": "38;2;197;134;192;48;2;15;18;24" }, { "text": " answer = ", - "pen": "48;5;235" + "pen": "48;2;15;18;24" }, { "text": "\"text\"", - "pen": "38;5;173;48;5;235" + "pen": "38;2;206;145;120;48;2;15;18;24" }, { "text": " ", - "pen": "48;5;235" + "pen": "48;2;15;18;24" }, { - "text": "// a comment ", - "pen": "48;5;235;2" + "text": "// a comment ", + "pen": "48;2;15;18;24;2" } ], [ @@ -2433,8 +2750,12 @@ "pen": "" }, { - "text": " ", - "pen": "48;5;235" + "text": "│ ", + "pen": "90" + }, + { + "text": " ", + "pen": "48;2;15;18;24" } ], [ @@ -2454,7 +2775,7 @@ }, { "text": "╭───────────────────────────────────────────────────────────────────────────────╮", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -2464,7 +2785,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -2472,7 +2793,7 @@ }, { "text": "›", - "pen": "35" + "pen": "38;2;77;107;254" }, { "text": " ", @@ -2488,7 +2809,7 @@ }, { "text": "│", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -2498,7 +2819,7 @@ }, { "text": "╰───────────────────────────────────────────────────────────────────────────────╯", - "pen": "36" + "pen": "38;2;77;107;254" } ], [ @@ -2563,7 +2884,7 @@ }, { "text": "CODE_CLI_HEADING", - "pen": "38;5;172" + "pen": "38;2;126;150;245" } ], [], @@ -2598,7 +2919,7 @@ }, { "text": "link", - "pen": "1" + "pen": "38;2;126;150;245;1" }, { "text": " ", @@ -3006,7 +3327,7 @@ }, { "text": " ts", - "pen": "90;48;5;235" + "pen": "90;48;2;15;18;24" } ], [ @@ -3016,27 +3337,27 @@ }, { "text": " ", - "pen": "48;5;235" + "pen": "48;2;15;18;24" }, { "text": "const", - "pen": "38;5;176;48;5;235" + "pen": "38;2;197;134;192;48;2;15;18;24" }, { "text": " answer = ", - "pen": "48;5;235" + "pen": "48;2;15;18;24" }, { "text": "\"text\"", - "pen": "38;5;173;48;5;235" + "pen": "38;2;206;145;120;48;2;15;18;24" }, { "text": " ", - "pen": "48;5;235" + "pen": "48;2;15;18;24" }, { "text": "// a comment", - "pen": "48;5;235;2" + "pen": "48;2;15;18;24;2" } ], [ @@ -3046,7 +3367,7 @@ }, { "text": " ", - "pen": "48;5;235" + "pen": "48;2;15;18;24" } ], [], diff --git a/site/guide.html b/site/guide.html index 0ea34df9..fd3b3194 100644 --- a/site/guide.html +++ b/site/guide.html @@ -197,20 +197,9 @@ <h2>Every terminal below is a real one.</h2> <span class="frame__real">real capture</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span style="color:rgb(126,150,245)">█▄▄</span></span> -<span class="row"> <span style="color:rgb(126,150,245)">█</span> <span style="color:rgb(126,150,245)">▀██</span></span> -<span class="row"> <span style="color:rgb(126,150,245)">█▄█▀▀</span></span> -<span class="row"> <span style="color:rgb(238,241,251)">▀██████▀</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">█▄██▄▄██▄██▄</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">▀▄▄</span> <span style="color:rgb(61,86,214)">██</span> <span style="color:rgb(61,86,214)">▄▄▀</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">▀█▄▄█▀</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-user">✻</span><span class="c-dim"> Welcome to codsh · cli-mock · code-cli</span></span> -<span class="row"> <span class="c-dim">/help · /status · Tab · ⇧Tab plan · Ctrl-C · /exit</span></span> -<span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">cli-mock · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> </figure> @@ -224,33 +213,9 @@ <h2>Every terminal below is a real one.</h2> <span class="frame__real">real capture</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span style="color:rgb(126,150,245)">█▄▄</span></span> -<span class="row"> <span style="color:rgb(126,150,245)">█</span> <span style="color:rgb(126,150,245)">▀██</span></span> -<span class="row"> <span style="color:rgb(126,150,245)">█▄█▀▀</span></span> -<span class="row"> <span style="color:rgb(238,241,251)">▀██████▀</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">█▄██▄▄██▄██▄</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">▀▄▄</span> <span style="color:rgb(61,86,214)">██</span> <span style="color:rgb(61,86,214)">▄▄▀</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">▀█▄▄█▀</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-user">✻</span><span class="c-dim"> Welcome to codsh · cli-mock · code-cli</span></span> -<span class="row"> <span class="c-dim">/help · /status · Tab · ⇧Tab plan · Ctrl-C · /exit</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-tool">› </span> where does the retry live? </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> </span> -<span class="row"> <span class="c-user">✻ </span><span class="c-dim"> thought for 0.1s · total 0.1s </span></span> -<span class="row"> </span> -<span class="row"> </span> -<span class="row"> CODE_CLI_ANSWER after thinking (turn 2)</span> -<span class="row"> </span> -<span class="row"> <span class="c-dim"> 0.1s (thought 0.1s) · 14 tokens</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">⠋</span> working <span class="c-dim">0.0s · Ctrl-C to interrupt</span></span> <span class="row"> <span class="c-dim">cli-mock · context 59/? · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> @@ -265,39 +230,39 @@ <h2>Every terminal below is a real one.</h2> <span class="frame__real">real capture</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> explain the render path ↑</span> -<span class="row"> <span class="c-tool">●</span></span> -<span class="row"> <span class="c-tool">› </span> <span class="a-dim">·</span></span> -<span class="row"> <span class="c-tool">› </span> now the input path ↓</span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> </span> -<span class="row"> <span class="c-dim">CODE_CLI_HEADING</span></span> -<span class="row"> </span> -<span class="row"> Prose with <span class="a-bold">bold</span>, <span class="a-bold">em</span>, <span class="c-dim">inline_code</span>, and a <span class="a-bold">link</span> <span class="c-dim">(https://x.dev)</span>.</span> -<span class="row"> An identifier like some_helper_name must survive intact.</span> -<span class="row"> Gain: <span class="c-ok">CODE_CLI_GAIN</span> & <span class="a-bold">held</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-dim">•</span> <span class="c-dim a-bold">screen.ts</span>: the viewport module</span> -<span class="row"> <span class="c-dim">•</span> second bullet</span> -<span class="row"> <span class="c-dim">•</span> third bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> fourth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> fifth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> sixth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> seventh bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> eighth bullet: past the fold threshold at any test width</span> -<span class="gap"></span> -<span class="row"> <span class="c-dim">╭────────┬──────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="a-bold">维度</span> <span class="c-dim">│</span> <span class="a-bold">内容</span> <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">├────────┼──────────────────────────────────────────────────────────────────────┤</span></span> -<span class="row"> <span class="c-dim">│</span> 一句话 <span class="c-dim">│</span> 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 宽度下都必须在单元格内部换行。 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">├────────┼──────────────────────────────────────────────────────────────────────┤</span></span> +<span class="row"> <span style="background:rgb(21,26,48)"> </span>↑</span> +<span class="row"> <span style="color:rgb(77,107,254);background:rgb(21,26,48)">│ </span><span style="background:rgb(21,26,48)"> now the input path </span><span class="a-dim">·</span></span> +<span class="row"> <span style="background:rgb(21,26,48)"> </span><span style="color:rgb(77,107,254)">●</span></span> +<span class="row"> <span class="c-dim">─────────────────────────────────────────────────────────────────────────────────</span><span class="a-dim">↓</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> third bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> fourth bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> fifth bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> sixth bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> seventh bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> eighth bullet: past the fold threshold at any test width</span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">╭────────┬─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─╮</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="a-bold">维度</span> <span class="c-dim">│</span> <span class="a-bold">内容</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">├────────┼─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─┤</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> 一句话 <span class="c-dim">│</span> 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 宽度下都必须在单元格内部换行。</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">├────────┼─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─┤</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> 命令 <span class="c-dim">│</span> <span class="c-dim">codsh</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> <span class="row"> <span class="c-dim"> ↑ 12 rows above · click or PgDn returns to the latest</span></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">cli-mock · context 207/? · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> </figure> @@ -311,19 +276,19 @@ <h2>Every terminal below is a real one.</h2> <span class="frame__real">real capture</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-tool">› </span> create the note </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-dim">│ </span> </span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-ok">●</span> <span class="c-dim">Write note.txt</span> <span class="c-dim">+1 -0</span> <span class="c-ok">✔ </span></span> -<span class="row"> <span class="c-dim">│ </span> </span> -<span class="row"> </span> -<span class="row"> CODE_CLI_CALL_OK via cli-mock</span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> create the note </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ ●</span> Write note.txt <span class="c-dim">+1 -0</span> <span class="c-ok">✔</span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span>CODE_CLI_CALL_OK via cli-mock</span> +<span class="row"> <span class="c-dim">│</span></span> <span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> -<span class="row"> <span class="c-dim">⠙</span> working <span class="c-dim">0.1s · 14 tokens · Ctrl-C to interrupt</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span class="c-dim">⠋</span> working <span class="c-dim">0.1s · 14 tokens · Ctrl-C to interrupt</span></span> <span class="row"> <span class="c-dim">cli-mock · context 23/? · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> </figure> @@ -333,30 +298,25 @@ <h2>Every terminal below is a real one.</h2> <figure class="frame"> <div class="frame__bar"> <i class="shell__dot"></i><i class="shell__dot"></i><i class="shell__dot"></i> - <b>Thinking, open while you read it</b> + <b>Thinking, folded until you open it</b> <span class="frame__real">real capture</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-tool">› </span> think it over </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> </span> -<span class="row"> <span class="c-user">✻ </span><span class="c-dim"> thought for 0.1s </span></span> -<span class="row"> </span> -<span class="row"> <span class="c-dim"> CODE_CLI_THINKING about the request </span></span> -<span class="row"> <span class="c-dim"> weighing the options carefully </span></span> -<span class="row"> </span> -<span class="row"> </span> -<span class="row"> CODE_CLI_ANSWER after thinking (turn 2)</span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> think it over </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span style="color:rgb(126,150,245)">│ </span><span class="c-dim"> thought for 0.0s</span></span> +<span class="row"> <span class="c-dim">│ </span>CODE_CLI_ANSWER after thinking (turn 2)</span> +<span class="row"> <span class="c-dim">│</span></span> <span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> -<span class="row"> <span class="c-dim">⠙</span> working <span class="c-dim">0.1s · Ctrl-C to interrupt</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span class="c-dim">⠋</span> working <span class="c-dim">0.0s · Ctrl-C to interrupt</span></span> <span class="row"> <span class="c-dim">cli-mock · context 45/? · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> </figure> - <p class="caption">A thought streams into the transcript under a head that becomes its clock, and stays open until the next prompt folds it. A click folds it now, a click on the clock opens it again, and Ctrl+O swaps every block at once.</p> + <p class="caption">A thought streams as a ticking `thinking…` head that becomes its clock, folded, with the deliberation behind a click or Ctrl+O. A click on the clock opens it, a click inside folds it, and Ctrl+O swaps every block at once.</p> </div> <div data-scene="6"> <figure class="frame"> @@ -366,24 +326,25 @@ <h2>Every terminal below is a real one.</h2> <span class="frame__real">real capture</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-tool">› </span> plan the work </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-dim">│ </span> </span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-ok">●</span> <span class="c-dim">Update todo list</span> <span class="c-dim">· 1 line</span> <span class="c-ok">✔ </span></span> -<span class="row"> <span class="c-dim">│ </span> </span> -<span class="row"> <span class="c-dim">│ </span><span class="c-dim">todos</span> <span class="c-dim">1/3 · 1 in progress · 1 open </span></span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-ok">✔</span> <span class="c-dim">read the code </span></span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">▶</span> write the fix </span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">○</span> run the tests </span> -<span class="row"> </span> -<span class="row"> CODE_CLI_CALL_OK via cli-mock</span> -<span class="row"> </span> -<span class="row"> <span class="c-dim"> 0.1s · 26 tokens</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> plan the work </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ ●</span> Update todo list <span class="c-dim">· 1 line</span> <span class="c-ok">✔</span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">todos</span> <span class="c-dim">1/3 · 1 in progress · 1 open</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-ok">✔</span> <span class="c-dim">read the code</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">▶</span> write the fix</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">○</span> run the tests</span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span>CODE_CLI_CALL_OK via cli-mock</span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ 0.1s · 26 tokens</span></span> +<span class="row"> <span class="c-dim">│</span></span> <span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">todos</span> <span class="c-dim">1/3 · Ctrl+T closes</span></span> <span class="row"> <span class="c-ok">✔</span> <span class="c-dim">read the code</span></span> <span class="row"> <span class="c-dim">▶</span> write the fix</span> @@ -401,37 +362,37 @@ <h2>Every terminal below is a real one.</h2> <span class="frame__real">real capture</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> explain it </span> -<span class="row"> </span> +<span class="row"> <span style="color:rgb(77,107,254);background:rgb(21,26,48)">│ </span><span style="background:rgb(21,26,48)"> explain it </span></span> +<span class="row"> <span style="background:rgb(21,26,48)"> </span></span> <span class="row"> <span class="c-dim">─────────────────────────────────────────────────────────────────────────────────</span></span> -<span class="row"> <span class="c-dim">•</span> second bullet</span> -<span class="row"> <span class="c-dim">•</span> third bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> fourth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> fifth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> sixth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> seventh bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> eighth bullet: past the fold threshold at any test width</span> -<span class="gap"></span> -<span class="row"> <span class="c-dim">╭────────┬──────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="a-bold">维度</span> <span class="c-dim">│</span> <span class="a-bold">内容</span> <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">├────────┼──────────────────────────────────────────────────────────────────────┤</span></span> -<span class="row"> <span class="c-dim">│</span> 一句话 <span class="c-dim">│</span> 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 宽度下都必须在单元格内部换行。 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">├────────┼──────────────────────────────────────────────────────────────────────┤</span></span> -<span class="row"> <span class="c-dim">│</span> 命令 <span class="c-dim">│</span> <span class="c-dim">codsh</span> <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">╰────────┴──────────────────────────────────────────────────────────────────────╯</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">a quoted line</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-dim"> ts </span></span> -<span class="row"> <span class="c-dim">const</span> answer = <span class="c-dim">"text"</span> <span class="a-dim">// a comment </span></span> -<span class="row"> </span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="a-bold">维度</span> <span class="c-dim">│</span> <span class="a-bold">内容</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">├────────┼─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─┤</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> 一句话 <span class="c-dim">│</span> 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 宽度下都必须在单元格内部换行。</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">├────────┼─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─┤</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> 命令 <span class="c-dim">│</span> <span class="c-dim">codsh</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">╰────────┴─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─╯</span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">a quoted line</span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim" style="background:rgb(15,18,24)"> ts </span></span> +<span class="row"> <span class="c-dim">│ </span><span style="background:rgb(15,18,24)"> </span><span style="color:rgb(197,134,192);background:rgb(15,18,24)">const</span><span style="background:rgb(15,18,24)"> answer = </span><span style="color:rgb(206,145,120);background:rgb(15,18,24)">"text"</span><span style="background:rgb(15,18,24)"> </span><span class="a-dim" style="background:rgb(15,18,24)">// a comment </span></span> +<span class="row"> <span class="c-dim">│ </span><span style="background:rgb(15,18,24)"> </span></span> <span class="row"> <span class="c-dim">CODE_CLI_CALL_STREAM_DONE</span></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">⠋</span> working <span class="c-dim">0.0s · Ctrl-C to interrupt</span></span> <span class="row"> <span class="c-dim">cli-mock · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> @@ -447,9 +408,9 @@ <h2>Every terminal below is a real one.</h2> </div> <div class="screen"><code class="screen__rows"> <span class="row"> <span class="a-bold">Answer 1</span></span> -<span class="row"> <span class="c-dim">CODE_CLI_HEADING</span></span> +<span class="row"> <span style="color:rgb(126,150,245)">CODE_CLI_HEADING</span></span> <span class="row"> </span> -<span class="row"> Prose with <span class="a-bold">bold</span>, <span class="a-bold">em</span>, <span class="c-dim">inline_code</span>, and a <span class="a-bold">link</span> <span class="c-dim">(https://x.dev)</span>.</span> +<span class="row"> Prose with <span class="a-bold">bold</span>, <span class="a-bold">em</span>, <span class="c-dim">inline_code</span>, and a <span class="a-bold" style="color:rgb(126,150,245)">link</span> <span class="c-dim">(https://x.dev)</span>.</span> <span class="row"> An identifier like some_helper_name must survive intact.</span> <span class="row"> Gain: <span class="c-ok">CODE_CLI_GAIN</span> & <span class="a-bold">held</span></span> <span class="row"> </span> @@ -475,9 +436,9 @@ <h2>Every terminal below is a real one.</h2> <span class="row"> </span> <span class="row"> <span class="c-dim">│</span> <span class="c-dim">a quoted line</span></span> <span class="row"> </span> -<span class="row"> <span class="c-dim"> ts</span></span> -<span class="row"> <span class="c-dim">const</span> answer = <span class="c-dim">"text"</span> <span class="a-dim">// a comment</span></span> -<span class="row"> </span> +<span class="row"> <span class="c-dim" style="background:rgb(15,18,24)"> ts</span></span> +<span class="row"> <span style="background:rgb(15,18,24)"> </span><span style="color:rgb(197,134,192);background:rgb(15,18,24)">const</span><span style="background:rgb(15,18,24)"> answer = </span><span style="color:rgb(206,145,120);background:rgb(15,18,24)">"text"</span><span style="background:rgb(15,18,24)"> </span><span class="a-dim" style="background:rgb(15,18,24)">// a comment</span></span> +<span class="row"> <span style="background:rgb(15,18,24)"> </span></span> <span class="row"> </span> <span class="row"> <span class="c-dim">Esc closes · c copies · ↑↓/wheel · PgUp/PgDn · Home/End · 1-32/33</span></span> </code></div> diff --git a/site/guide.zh.html b/site/guide.zh.html index 91137468..bb36c89c 100644 --- a/site/guide.zh.html +++ b/site/guide.zh.html @@ -197,20 +197,9 @@ <h2>下面每一个终端都是真的。</h2> <span class="frame__real">真实抓取</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span style="color:rgb(126,150,245)">█▄▄</span></span> -<span class="row"> <span style="color:rgb(126,150,245)">█</span> <span style="color:rgb(126,150,245)">▀██</span></span> -<span class="row"> <span style="color:rgb(126,150,245)">█▄█▀▀</span></span> -<span class="row"> <span style="color:rgb(238,241,251)">▀██████▀</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">█▄██▄▄██▄██▄</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">▀▄▄</span> <span style="color:rgb(61,86,214)">██</span> <span style="color:rgb(61,86,214)">▄▄▀</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">▀█▄▄█▀</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-user">✻</span><span class="c-dim"> Welcome to codsh · cli-mock · code-cli</span></span> -<span class="row"> <span class="c-dim">/help · /status · Tab · ⇧Tab plan · Ctrl-C · /exit</span></span> -<span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">cli-mock · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> </figure> @@ -224,33 +213,9 @@ <h2>下面每一个终端都是真的。</h2> <span class="frame__real">真实抓取</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span style="color:rgb(126,150,245)">█▄▄</span></span> -<span class="row"> <span style="color:rgb(126,150,245)">█</span> <span style="color:rgb(126,150,245)">▀██</span></span> -<span class="row"> <span style="color:rgb(126,150,245)">█▄█▀▀</span></span> -<span class="row"> <span style="color:rgb(238,241,251)">▀██████▀</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">█▄██▄▄██▄██▄</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">▀▄▄</span> <span style="color:rgb(61,86,214)">██</span> <span style="color:rgb(61,86,214)">▄▄▀</span></span> -<span class="row"> <span style="color:rgb(61,86,214)">▀█▄▄█▀</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-user">✻</span><span class="c-dim"> Welcome to codsh · cli-mock · code-cli</span></span> -<span class="row"> <span class="c-dim">/help · /status · Tab · ⇧Tab plan · Ctrl-C · /exit</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-tool">› </span> where does the retry live? </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> </span> -<span class="row"> <span class="c-user">✻ </span><span class="c-dim"> thought for 0.1s · total 0.1s </span></span> -<span class="row"> </span> -<span class="row"> </span> -<span class="row"> CODE_CLI_ANSWER after thinking (turn 2)</span> -<span class="row"> </span> -<span class="row"> <span class="c-dim"> 0.1s (thought 0.1s) · 14 tokens</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">⠋</span> working <span class="c-dim">0.0s · Ctrl-C to interrupt</span></span> <span class="row"> <span class="c-dim">cli-mock · context 59/? · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> @@ -265,39 +230,39 @@ <h2>下面每一个终端都是真的。</h2> <span class="frame__real">真实抓取</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> explain the render path ↑</span> -<span class="row"> <span class="c-tool">●</span></span> -<span class="row"> <span class="c-tool">› </span> <span class="a-dim">·</span></span> -<span class="row"> <span class="c-tool">› </span> now the input path ↓</span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> </span> -<span class="row"> <span class="c-dim">CODE_CLI_HEADING</span></span> -<span class="row"> </span> -<span class="row"> Prose with <span class="a-bold">bold</span>, <span class="a-bold">em</span>, <span class="c-dim">inline_code</span>, and a <span class="a-bold">link</span> <span class="c-dim">(https://x.dev)</span>.</span> -<span class="row"> An identifier like some_helper_name must survive intact.</span> -<span class="row"> Gain: <span class="c-ok">CODE_CLI_GAIN</span> & <span class="a-bold">held</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-dim">•</span> <span class="c-dim a-bold">screen.ts</span>: the viewport module</span> -<span class="row"> <span class="c-dim">•</span> second bullet</span> -<span class="row"> <span class="c-dim">•</span> third bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> fourth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> fifth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> sixth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> seventh bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> eighth bullet: past the fold threshold at any test width</span> -<span class="gap"></span> -<span class="row"> <span class="c-dim">╭────────┬──────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="a-bold">维度</span> <span class="c-dim">│</span> <span class="a-bold">内容</span> <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">├────────┼──────────────────────────────────────────────────────────────────────┤</span></span> -<span class="row"> <span class="c-dim">│</span> 一句话 <span class="c-dim">│</span> 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 宽度下都必须在单元格内部换行。 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">├────────┼──────────────────────────────────────────────────────────────────────┤</span></span> +<span class="row"> <span style="background:rgb(21,26,48)"> </span>↑</span> +<span class="row"> <span style="color:rgb(77,107,254);background:rgb(21,26,48)">│ </span><span style="background:rgb(21,26,48)"> now the input path </span><span class="a-dim">·</span></span> +<span class="row"> <span style="background:rgb(21,26,48)"> </span><span style="color:rgb(77,107,254)">●</span></span> +<span class="row"> <span class="c-dim">─────────────────────────────────────────────────────────────────────────────────</span><span class="a-dim">↓</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> third bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> fourth bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> fifth bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> sixth bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> seventh bullet keeps the answer long</span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">•</span> eighth bullet: past the fold threshold at any test width</span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">╭────────┬─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─╮</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="a-bold">维度</span> <span class="c-dim">│</span> <span class="a-bold">内容</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">├────────┼─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─┤</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> 一句话 <span class="c-dim">│</span> 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 宽度下都必须在单元格内部换行。</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">├────────┼─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─┤</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> 命令 <span class="c-dim">│</span> <span class="c-dim">codsh</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> <span class="row"> <span class="c-dim"> ↑ 12 rows above · click or PgDn returns to the latest</span></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">cli-mock · context 207/? · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> </figure> @@ -311,19 +276,19 @@ <h2>下面每一个终端都是真的。</h2> <span class="frame__real">真实抓取</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-tool">› </span> create the note </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-dim">│ </span> </span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-ok">●</span> <span class="c-dim">Write note.txt</span> <span class="c-dim">+1 -0</span> <span class="c-ok">✔ </span></span> -<span class="row"> <span class="c-dim">│ </span> </span> -<span class="row"> </span> -<span class="row"> CODE_CLI_CALL_OK via cli-mock</span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> create the note </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ ●</span> Write note.txt <span class="c-dim">+1 -0</span> <span class="c-ok">✔</span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span>CODE_CLI_CALL_OK via cli-mock</span> +<span class="row"> <span class="c-dim">│</span></span> <span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> -<span class="row"> <span class="c-dim">⠙</span> working <span class="c-dim">0.1s · 14 tokens · Ctrl-C to interrupt</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span class="c-dim">⠋</span> working <span class="c-dim">0.1s · 14 tokens · Ctrl-C to interrupt</span></span> <span class="row"> <span class="c-dim">cli-mock · context 23/? · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> </figure> @@ -337,22 +302,17 @@ <h2>下面每一个终端都是真的。</h2> <span class="frame__real">真实抓取</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-tool">› </span> think it over </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> </span> -<span class="row"> <span class="c-user">✻ </span><span class="c-dim"> thought for 0.1s </span></span> -<span class="row"> </span> -<span class="row"> <span class="c-dim"> CODE_CLI_THINKING about the request </span></span> -<span class="row"> <span class="c-dim"> weighing the options carefully </span></span> -<span class="row"> </span> -<span class="row"> </span> -<span class="row"> CODE_CLI_ANSWER after thinking (turn 2)</span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> think it over </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span style="color:rgb(126,150,245)">│ </span><span class="c-dim"> thought for 0.0s</span></span> +<span class="row"> <span class="c-dim">│ </span>CODE_CLI_ANSWER after thinking (turn 2)</span> +<span class="row"> <span class="c-dim">│</span></span> <span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> -<span class="row"> <span class="c-dim">⠙</span> working <span class="c-dim">0.1s · Ctrl-C to interrupt</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span class="c-dim">⠋</span> working <span class="c-dim">0.0s · Ctrl-C to interrupt</span></span> <span class="row"> <span class="c-dim">cli-mock · context 45/? · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> </figure> @@ -366,24 +326,25 @@ <h2>下面每一个终端都是真的。</h2> <span class="frame__real">真实抓取</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-tool">› </span> plan the work </span> -<span class="row"> <span class="c-tool">› </span> </span> -<span class="row"> <span class="c-dim">│ </span> </span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-ok">●</span> <span class="c-dim">Update todo list</span> <span class="c-dim">· 1 line</span> <span class="c-ok">✔ </span></span> -<span class="row"> <span class="c-dim">│ </span> </span> -<span class="row"> <span class="c-dim">│ </span><span class="c-dim">todos</span> <span class="c-dim">1/3 · 1 in progress · 1 open </span></span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-ok">✔</span> <span class="c-dim">read the code </span></span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">▶</span> write the fix </span> -<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">○</span> run the tests </span> -<span class="row"> </span> -<span class="row"> CODE_CLI_CALL_OK via cli-mock</span> -<span class="row"> </span> -<span class="row"> <span class="c-dim"> 0.1s · 26 tokens</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> plan the work </span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│ </span><span style="background:rgb(21,26,48)"> </span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ ●</span> Update todo list <span class="c-dim">· 1 line</span> <span class="c-ok">✔</span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">todos</span> <span class="c-dim">1/3 · 1 in progress · 1 open</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-ok">✔</span> <span class="c-dim">read the code</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">▶</span> write the fix</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">○</span> run the tests</span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span>CODE_CLI_CALL_OK via cli-mock</span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ 0.1s · 26 tokens</span></span> +<span class="row"> <span class="c-dim">│</span></span> <span class="gap"></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">todos</span> <span class="c-dim">1/3 · Ctrl+T closes</span></span> <span class="row"> <span class="c-ok">✔</span> <span class="c-dim">read the code</span></span> <span class="row"> <span class="c-dim">▶</span> write the fix</span> @@ -401,37 +362,37 @@ <h2>下面每一个终端都是真的。</h2> <span class="frame__real">真实抓取</span> </div> <div class="screen"><code class="screen__rows"> -<span class="row"> <span class="c-tool">› </span> explain it </span> -<span class="row"> </span> +<span class="row"> <span style="color:rgb(77,107,254);background:rgb(21,26,48)">│ </span><span style="background:rgb(21,26,48)"> explain it </span></span> +<span class="row"> <span style="background:rgb(21,26,48)"> </span></span> <span class="row"> <span class="c-dim">─────────────────────────────────────────────────────────────────────────────────</span></span> -<span class="row"> <span class="c-dim">•</span> second bullet</span> -<span class="row"> <span class="c-dim">•</span> third bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> fourth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> fifth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> sixth bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> seventh bullet keeps the answer long</span> -<span class="row"> <span class="c-dim">•</span> eighth bullet: past the fold threshold at any test width</span> -<span class="gap"></span> -<span class="row"> <span class="c-dim">╭────────┬──────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="a-bold">维度</span> <span class="c-dim">│</span> <span class="a-bold">内容</span> <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">├────────┼──────────────────────────────────────────────────────────────────────┤</span></span> -<span class="row"> <span class="c-dim">│</span> 一句话 <span class="c-dim">│</span> 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">│</span> 宽度下都必须在单元格内部换行。 <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">├────────┼──────────────────────────────────────────────────────────────────────┤</span></span> -<span class="row"> <span class="c-dim">│</span> 命令 <span class="c-dim">│</span> <span class="c-dim">codsh</span> <span class="c-dim">│</span></span> -<span class="row"> <span class="c-dim">╰────────┴──────────────────────────────────────────────────────────────────────╯</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-dim">│</span> <span class="c-dim">a quoted line</span></span> -<span class="row"> </span> -<span class="row"> <span class="c-dim"> ts </span></span> -<span class="row"> <span class="c-dim">const</span> answer = <span class="c-dim">"text"</span> <span class="a-dim">// a comment </span></span> -<span class="row"> </span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="a-bold">维度</span> <span class="c-dim">│</span> <span class="a-bold">内容</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">├────────┼─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─┤</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> 一句话 <span class="c-dim">│</span> 一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必须在单元格</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 内部换行。一个很长的中文单元格内容,用来强制表格在任何终端宽度下都必</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 须在单元格内部换行。一个很长的中文单元格内容,用来强制表格在任何终端</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">│</span> 宽度下都必须在单元格内部换行。</span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">├────────┼─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─┤</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> 命令 <span class="c-dim">│</span> <span class="c-dim">codsh</span></span> +<span class="row"> <span class="c-dim">│ </span> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">╰────────┴─────────────────────────────────────────────────────────────────────</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">─╯</span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim">│</span> <span class="c-dim">a quoted line</span></span> +<span class="row"> <span class="c-dim">│</span></span> +<span class="row"> <span class="c-dim">│ </span><span class="c-dim" style="background:rgb(15,18,24)"> ts </span></span> +<span class="row"> <span class="c-dim">│ </span><span style="background:rgb(15,18,24)"> </span><span style="color:rgb(197,134,192);background:rgb(15,18,24)">const</span><span style="background:rgb(15,18,24)"> answer = </span><span style="color:rgb(206,145,120);background:rgb(15,18,24)">"text"</span><span style="background:rgb(15,18,24)"> </span><span class="a-dim" style="background:rgb(15,18,24)">// a comment </span></span> +<span class="row"> <span class="c-dim">│ </span><span style="background:rgb(15,18,24)"> </span></span> <span class="row"> <span class="c-dim">CODE_CLI_CALL_STREAM_DONE</span></span> -<span class="row"> <span class="c-tool">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> -<span class="row"> <span class="c-tool">│</span> <span class="c-user">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span class="c-tool">│</span></span> -<span class="row"> <span class="c-tool">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╭───────────────────────────────────────────────────────────────────────────────╮</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">│</span> <span style="color:rgb(77,107,254)">›</span> <span class="c-dim">Ask anything · / for commands · $ for skills · ! shell · @ for files · ⇧Ta…</span> <span style="color:rgb(77,107,254)">│</span></span> +<span class="row"> <span style="color:rgb(77,107,254)">╰───────────────────────────────────────────────────────────────────────────────╯</span></span> <span class="row"> <span class="c-dim">⠋</span> working <span class="c-dim">0.0s · Ctrl-C to interrupt</span></span> <span class="row"> <span class="c-dim">cli-mock · ~/.cache/codsh/showcase · ? shortcuts</span></span> </code></div> @@ -447,9 +408,9 @@ <h2>下面每一个终端都是真的。</h2> </div> <div class="screen"><code class="screen__rows"> <span class="row"> <span class="a-bold">Answer 1</span></span> -<span class="row"> <span class="c-dim">CODE_CLI_HEADING</span></span> +<span class="row"> <span style="color:rgb(126,150,245)">CODE_CLI_HEADING</span></span> <span class="row"> </span> -<span class="row"> Prose with <span class="a-bold">bold</span>, <span class="a-bold">em</span>, <span class="c-dim">inline_code</span>, and a <span class="a-bold">link</span> <span class="c-dim">(https://x.dev)</span>.</span> +<span class="row"> Prose with <span class="a-bold">bold</span>, <span class="a-bold">em</span>, <span class="c-dim">inline_code</span>, and a <span class="a-bold" style="color:rgb(126,150,245)">link</span> <span class="c-dim">(https://x.dev)</span>.</span> <span class="row"> An identifier like some_helper_name must survive intact.</span> <span class="row"> Gain: <span class="c-ok">CODE_CLI_GAIN</span> & <span class="a-bold">held</span></span> <span class="row"> </span> @@ -475,9 +436,9 @@ <h2>下面每一个终端都是真的。</h2> <span class="row"> </span> <span class="row"> <span class="c-dim">│</span> <span class="c-dim">a quoted line</span></span> <span class="row"> </span> -<span class="row"> <span class="c-dim"> ts</span></span> -<span class="row"> <span class="c-dim">const</span> answer = <span class="c-dim">"text"</span> <span class="a-dim">// a comment</span></span> -<span class="row"> </span> +<span class="row"> <span class="c-dim" style="background:rgb(15,18,24)"> ts</span></span> +<span class="row"> <span style="background:rgb(15,18,24)"> </span><span style="color:rgb(197,134,192);background:rgb(15,18,24)">const</span><span style="background:rgb(15,18,24)"> answer = </span><span style="color:rgb(206,145,120);background:rgb(15,18,24)">"text"</span><span style="background:rgb(15,18,24)"> </span><span class="a-dim" style="background:rgb(15,18,24)">// a comment</span></span> +<span class="row"> <span style="background:rgb(15,18,24)"> </span></span> <span class="row"> </span> <span class="row"> <span class="c-dim">Esc closes · c copies · ↑↓/wheel · PgUp/PgDn · Home/End · 1-32/33</span></span> </code></div>