dsh-ssh-tui 0.8.0 → 0.8.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +150 -0
  2. package/README.en.md +74 -8
  3. package/README.md +451 -577
  4. package/docs/display-mode.md +122 -0
  5. package/docs/release-notes-0.7.4.md +91 -0
  6. package/docs/release-notes-0.8.0.md +56 -0
  7. package/docs/release-notes-0.8.1.md +79 -0
  8. package/docs/release-notes-0.8.2-rc.1.md +159 -0
  9. package/docs/remote-ops.md +51 -0
  10. package/docs/terminals.md +53 -0
  11. package/docs/windows.md +13 -0
  12. package/lib/approval-state.js +148 -0
  13. package/lib/approval-state.js.map +1 -0
  14. package/lib/attach.js +4 -4
  15. package/lib/attach.js.map +1 -1
  16. package/lib/auth-failure.js +74 -0
  17. package/lib/auth-failure.js.map +1 -1
  18. package/lib/color-depth.js +26 -0
  19. package/lib/color-depth.js.map +1 -1
  20. package/lib/commands.js +2 -0
  21. package/lib/commands.js.map +1 -1
  22. package/lib/copy-text.js +30 -8
  23. package/lib/copy-text.js.map +1 -1
  24. package/lib/dialogs.js +124 -0
  25. package/lib/dialogs.js.map +1 -1
  26. package/lib/display-mode.js +147 -0
  27. package/lib/display-mode.js.map +1 -0
  28. package/lib/display-sock.js +490 -35
  29. package/lib/display-sock.js.map +1 -1
  30. package/lib/footer-accents.js +28 -0
  31. package/lib/footer-accents.js.map +1 -0
  32. package/lib/footer-budget.js +328 -0
  33. package/lib/footer-budget.js.map +1 -0
  34. package/lib/footer-format.js +433 -0
  35. package/lib/footer-format.js.map +1 -0
  36. package/lib/footer-layout.js +407 -0
  37. package/lib/footer-layout.js.map +1 -0
  38. package/lib/footer-meters.js +185 -0
  39. package/lib/footer-meters.js.map +1 -0
  40. package/lib/footer.js +19 -561
  41. package/lib/footer.js.map +1 -1
  42. package/lib/glyph-measure.js +92 -0
  43. package/lib/glyph-measure.js.map +1 -0
  44. package/lib/i18n/en.js +83 -7
  45. package/lib/i18n/en.js.map +1 -1
  46. package/lib/i18n/zh.js +85 -7
  47. package/lib/i18n/zh.js.map +1 -1
  48. package/lib/index.js +73 -5
  49. package/lib/index.js.map +1 -1
  50. package/lib/paint.js +75 -17
  51. package/lib/paint.js.map +1 -1
  52. package/lib/picker.js +14 -13
  53. package/lib/picker.js.map +1 -1
  54. package/lib/plan-projection.js +291 -0
  55. package/lib/plan-projection.js.map +1 -0
  56. package/lib/plan.js +61 -11
  57. package/lib/plan.js.map +1 -1
  58. package/lib/platform.js +96 -0
  59. package/lib/platform.js.map +1 -1
  60. package/lib/question-state.js +83 -0
  61. package/lib/question-state.js.map +1 -0
  62. package/lib/representation.js +283 -0
  63. package/lib/representation.js.map +1 -0
  64. package/lib/screen.js +105 -0
  65. package/lib/screen.js.map +1 -0
  66. package/lib/selection.js +5 -3
  67. package/lib/selection.js.map +1 -1
  68. package/lib/session-blank.js +81 -0
  69. package/lib/session-blank.js.map +1 -0
  70. package/lib/session-list.js +102 -81
  71. package/lib/session-list.js.map +1 -1
  72. package/lib/startup.js +56 -0
  73. package/lib/startup.js.map +1 -1
  74. package/lib/stats.js +101 -3
  75. package/lib/stats.js.map +1 -1
  76. package/lib/subagent-model.js +8 -7
  77. package/lib/subagent-model.js.map +1 -1
  78. package/lib/term-text.js +436 -69
  79. package/lib/term-text.js.map +1 -1
  80. package/lib/terminal-input.js +132 -10
  81. package/lib/terminal-input.js.map +1 -1
  82. package/lib/theme.js +335 -0
  83. package/lib/theme.js.map +1 -0
  84. package/lib/throughput.js +305 -0
  85. package/lib/throughput.js.map +1 -0
  86. package/lib/tool-present.js +67 -15
  87. package/lib/tool-present.js.map +1 -1
  88. package/lib/tui.js +4186 -1133
  89. package/lib/tui.js.map +1 -1
  90. package/lib/types/approval-state.d.ts +75 -0
  91. package/lib/types/attach.d.ts +6 -2
  92. package/lib/types/auth-failure.d.ts +55 -0
  93. package/lib/types/color-depth.d.ts +21 -0
  94. package/lib/types/commands.d.ts +6 -0
  95. package/lib/types/copy-text.d.ts +18 -1
  96. package/lib/types/dialogs.d.ts +147 -1
  97. package/lib/types/display-mode.d.ts +99 -0
  98. package/lib/types/display-sock.d.ts +90 -0
  99. package/lib/types/footer-accents.d.ts +22 -0
  100. package/lib/types/footer-budget.d.ts +204 -0
  101. package/lib/types/footer-format.d.ts +221 -0
  102. package/lib/types/footer-layout.d.ts +292 -0
  103. package/lib/types/footer-meters.d.ts +151 -0
  104. package/lib/types/footer.d.ts +19 -269
  105. package/lib/types/glyph-measure.d.ts +41 -0
  106. package/lib/types/index.d.ts +13 -0
  107. package/lib/types/paint.d.ts +48 -0
  108. package/lib/types/plan-projection.d.ts +162 -0
  109. package/lib/types/plan.d.ts +18 -2
  110. package/lib/types/platform.d.ts +83 -0
  111. package/lib/types/question-state.d.ts +96 -0
  112. package/lib/types/representation.d.ts +498 -0
  113. package/lib/types/screen.d.ts +126 -0
  114. package/lib/types/session-blank.d.ts +51 -0
  115. package/lib/types/session-list.d.ts +34 -0
  116. package/lib/types/startup.d.ts +30 -0
  117. package/lib/types/stats.d.ts +64 -1
  118. package/lib/types/subagent-model.d.ts +7 -6
  119. package/lib/types/term-text.d.ts +100 -24
  120. package/lib/types/terminal-input.d.ts +37 -0
  121. package/lib/types/theme.d.ts +109 -0
  122. package/lib/types/throughput.d.ts +163 -0
  123. package/lib/types/tool-present.d.ts +43 -2
  124. package/lib/types/transcript-types.d.ts +63 -2
  125. package/lib/types/tui.d.ts +960 -12
  126. package/package.json +73 -67
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The Screen contract: a whole-view surface with its own navigation state.
3
+ *
4
+ * A **Surface** (ask-user, a picker) borrows a few rows of the workspace and gives
5
+ * them back when the action is done: the transcript behind it never moves, and
6
+ * the reader returns to the same place. A **Screen** is the other thing entirely —
7
+ * it replaces the workspace, owns its own scrolling, and must be able to redraw
8
+ * itself from its state alone (a reattach after an SSH drop is a normal event, not
9
+ * an edge case). See `docs/decisions/b2-architecture-decisions.md` (AD-1 … AD-3).
10
+ *
11
+ * Depth is exactly one: a Screen may hold tabs, sections and detail modes of its
12
+ * own, but it never pushes another Screen. Nothing here allocates a stack, and
13
+ * nothing here renders — this module is the contract, `tui.ts` is the renderer.
14
+ */
15
+ import type { Dialog, InspectDialog } from './dialogs.js';
16
+ import type { DiffDisplayLine } from './tool-present.js';
17
+ /**
18
+ * Which Screen is up. Adding one here is the only way to add one at all.
19
+ *
20
+ * `setup` is the odd one: it draws no body of its own — the wizard's rows are built
21
+ * from its state machine every frame — but it is a Screen in every other sense. It
22
+ * owns the whole view, it owns its input, it never enters the dialog queue, and it is
23
+ * rebuilt from state after a reattach (B2.6).
24
+ */
25
+ export type ScreenKind = 'inspect' | 'report' | 'setup';
26
+ /** The report a `report` Screen is showing. */
27
+ export type ReportKind = 'status' | 'usage' | 'diag' | 'doctor' | 'help' | 'subagents';
28
+ /**
29
+ * A Screen's whole state.
30
+ *
31
+ * Everything the renderer needs is here on purpose: nothing about the picture
32
+ * lives only in the paint loop, which is what makes a reattach able to rebuild the
33
+ * same screen, and what makes the screen testable without a terminal. `offset` is
34
+ * the first visible body row — the Screen's own scroll position, unrelated to the
35
+ * transcript's `scrollOffset` behind it.
36
+ */
37
+ export interface ScreenState {
38
+ kind: ScreenKind;
39
+ /** Title row. */
40
+ title: string;
41
+ /** Body, already rendered for the width it will be shown at. */
42
+ lines: readonly DiffDisplayLine[];
43
+ /** First visible body row. */
44
+ offset: number;
45
+ /** Which report this is, when `kind === 'report'`. */
46
+ report?: ReportKind;
47
+ /** What the copy key takes while this screen is up. */
48
+ copyText?: string;
49
+ /**
50
+ * A confirmation line shown in place of the navigation hint.
51
+ *
52
+ * Used by an in-screen copy ("copied 412 chars") and by a Screen's own action
53
+ * (the doctor repair result): the row the action would otherwise push is behind
54
+ * this screen, and silence after an action reads as a failure.
55
+ */
56
+ notice?: string;
57
+ /** Child session whose live log refreshes this screen in place. */
58
+ subagentSessionId?: string;
59
+ /** `/find` already scrolled to its hit here; later repaints keep the offset. */
60
+ searchRevealed?: boolean;
61
+ }
62
+ /**
63
+ * The one judgement: is this dialog a Screen?
64
+ *
65
+ * A Screen is *not* a dialog — it never enters `dialogQueue`, is never replaced by
66
+ * a Surface and never blocks one. This function exists so that no future call site
67
+ * has to remember that: a screen-shaped dialog handed to the dialog path is
68
+ * converted here and handed to the Screen path instead, and a test pins it.
69
+ *
70
+ * Returns `undefined` for the surfaces that genuinely are dialogs (questions,
71
+ * confirms, the onboarding wizard).
72
+ */
73
+ export declare function screenFromDialog(dialog: Dialog | InspectDialog): ScreenState | undefined;
74
+ /**
75
+ * The row kind a report falls back to where there is no frame to draw on.
76
+ *
77
+ * Line mode has no Screen (and no way to dismiss one), so `/diag` and `/doctor`
78
+ * keep their own copyable `diag` rows there — which is also where they are read
79
+ * from in a transcript (`/copy error` looks for `error` or `diag`).
80
+ */
81
+ export declare function reportRowKind(report: ReportKind): 'diag' | 'system';
82
+ /** The rows a Screen lays out, top to bottom. */
83
+ export interface ScreenLayout {
84
+ /** 1 when the title row is drawn. */
85
+ titleRows: number;
86
+ /** 1 when the rule under the title is drawn. */
87
+ dividerRows: number;
88
+ /** Rows available to the body; may be 0 on a terminal that short. */
89
+ bodyRows: number;
90
+ /** 1 when the navigation hint row is drawn. */
91
+ hintRows: number;
92
+ /** 1 when the compact runtime strip is drawn. */
93
+ stripRows: number;
94
+ /** 0-based index of the first body row in the painted frame. */
95
+ contentTop: number;
96
+ /** 0-based index of the hint row, or -1 when it is not drawn. */
97
+ hintTop: number;
98
+ /** 0-based index of the strip row, or -1 when it is not drawn. */
99
+ stripTop: number;
100
+ }
101
+ /**
102
+ * Lay a Screen out for the height the terminal has.
103
+ *
104
+ * The strip is the one row that never goes: a Screen is a place the reader can be
105
+ * *while the agent keeps working*, and "is it still running, is anything waiting,
106
+ * is the link alive" has to be answerable without leaving. After that the order
107
+ * gives up the least load-bearing row first — the rule under the title, then the
108
+ * title, then body rows — and the navigation hint goes only when there is nothing
109
+ * left to navigate. Measured at 8 / 12 / 20 rows in the Screen contract tests.
110
+ */
111
+ export declare function screenLayout(height: number): ScreenLayout;
112
+ /**
113
+ * Clamp a Screen's scroll offset to its body.
114
+ *
115
+ * The body can be shorter than the last offset the reader scrolled to (a resize, a
116
+ * shorter report, a live subagent log that was replaced), and an offset past the
117
+ * end paints an empty screen with a position readout that lies.
118
+ */
119
+ export declare function clampScreenOffset(offset: number, lineCount: number, bodyRows: number): number;
120
+ /**
121
+ * `1–18/64`, the position readout a Screen's footer shows.
122
+ *
123
+ * Zero lines reads `0/0` rather than `1–0/0`: the second looks like a bug in the
124
+ * readout instead of an empty report.
125
+ */
126
+ export declare function screenPositionText(offset: number, lineCount: number, bodyRows: number): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What makes a session "blank": a launch that never did anything.
3
+ *
4
+ * A fresh TUI start creates a session before the reader has typed anything, so
5
+ * quitting straight away leaves an artifact behind. The picker hides those, but
6
+ * they are real files, and other surfaces (the web session list) read the same
7
+ * artifacts without that filter — which is how a session that was never used
8
+ * still shows up in another profile's menu.
9
+ *
10
+ * The rule lives in its own module, with no host imports, for one reason: three
11
+ * very different callers have to agree on it — the picker (which prunes while
12
+ * listing), the TUI (which drops its own session on exit), and
13
+ * `scripts/prune-blank-sessions.mjs` (which sweeps whatever already accumulated,
14
+ * outside a running host).
15
+ *
16
+ * @module dsh-ssh-tui/session-blank
17
+ */
18
+ /** Whether one event is a message the reader typed. */
19
+ export declare function isUserMessageEvent(event: unknown): boolean;
20
+ /**
21
+ * Whether a turn was opened and never closed.
22
+ *
23
+ * A session with an open turn is mid-work: it must never be treated as empty,
24
+ * however little it looks like it has done.
25
+ * @param events - decoded session events, in order.
26
+ * @returns true when a `turn/start` has no matching `turn/end`.
27
+ */
28
+ export declare function hasUnfinishedTurn(events: readonly unknown[]): boolean;
29
+ /**
30
+ * Whether the session ever produced a reply.
31
+ *
32
+ * A failed request counts: the error text is the only thing the reader has to
33
+ * look at, and deleting it would delete their evidence.
34
+ * @param events - decoded session events, in order.
35
+ * @returns true when an assistant message or a failed turn is present.
36
+ */
37
+ export declare function sessionHasReply(events: readonly unknown[]): boolean;
38
+ /**
39
+ * Whether a session saw the reader's input, a reply, or an open turn.
40
+ * @param events - decoded session events, in order.
41
+ * @returns true when there is something worth keeping.
42
+ */
43
+ export declare function sessionHasWork(events: readonly unknown[]): boolean;
44
+ /**
45
+ * Whether a session's events describe a launch that never did anything.
46
+ * @param events - the session's decoded events.
47
+ * @returns true when nothing the reader would recognise as work happened. An
48
+ * empty event list is *not* blank: it is an unreadable or detached log, and
49
+ * guessing there once made a listing delete a live session's directory.
50
+ */
51
+ export declare function sessionEventsAreBlank(events: readonly unknown[]): boolean;
@@ -4,6 +4,18 @@
4
4
  * labels, and ordering, so both surfaces offer the same sessions.
5
5
  */
6
6
  import { listAttachableHosts } from './session-lock.js';
7
+ /** Delete one session's on-disk artifacts (log directory), best effort. */
8
+ /**
9
+ * Delete one session's artifacts by id.
10
+ *
11
+ * `false` means the persistence layer could not locate it (already gone, or a
12
+ * service that does not expose `locate`) — never an error worth surfacing, since
13
+ * the only caller is tidying up on the way out.
14
+ * @param persistence - the session persistence service.
15
+ * @param id - the session id.
16
+ * @returns true when a directory was found and removed.
17
+ */
18
+ export declare function pruneSessionById(persistence: object | undefined, id: string): Promise<boolean>;
7
19
  /** Last path segment for the footer chip (`\root\genshin\srv` → `srv`). */
8
20
  export declare function sessionCwdLabel(cwd: string): string;
9
21
  export declare function formatFooterCwd(cwd: string): string;
@@ -88,6 +100,28 @@ export declare function openResumableSessionPager(persistence: object, currentId
88
100
  listHosts?: typeof listAttachableHosts;
89
101
  indexPath?: string;
90
102
  }): Promise<ResumableSessionPager>;
103
+ /**
104
+ * Delete every session that never saw user input, and report what happened.
105
+ *
106
+ * The picker prunes these as it resolves them, but only for the surface that
107
+ * looks at them: a launch that died before doing anything kept showing up in
108
+ * *other* profiles' menus (the web session list reads the same artifacts without
109
+ * this filter). So the same rule is offered as an explicit sweep, which is also
110
+ * what `/cleanup` runs.
111
+ * @param persistence - the session persistence service.
112
+ * @param currentId - the live session, never a candidate for deletion.
113
+ * @param options - `listHosts` for test injection.
114
+ * @returns how many were deleted, how many were kept, and how many could not be
115
+ * read (an unreadable log is kept: it might be a live writer).
116
+ */
117
+ export declare function pruneBlankSessions(persistence: object, currentId: string, options?: {
118
+ listHosts?: typeof listAttachableHosts;
119
+ dryRun?: boolean;
120
+ }): Promise<{
121
+ pruned: number;
122
+ kept: number;
123
+ unreadable: number;
124
+ }>;
91
125
  /**
92
126
  * List resumable top-level sessions, newest first.
93
127
  *
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { Context } from '@deepseek-ai/cordis';
8
8
  import { SessionId } from '@deepseek-ai/dsh-session';
9
+ import { type DisplayMode } from './display-mode.js';
9
10
  /** Service key under which the parsed TUI launch options are provided. */
10
11
  export declare const SSH_TUI_STARTUP_SERVICE = "sshTuiStartup";
11
12
  /** Config `id` of the agent-loop entry the TUI drives. */
@@ -24,6 +25,11 @@ export interface SshTuiStartup {
24
25
  readonly provider?: string;
25
26
  /** ANSI color opt-out supplied at launch. */
26
27
  readonly noColor?: boolean;
28
+ /**
29
+ * Where the bytes go: `stdio` when the parent speaks the display protocol on
30
+ * stdin/stdout (a GUI, a web terminal, a harness) instead of owning a TTY.
31
+ */
32
+ readonly display?: DisplayMode;
27
33
  }
28
34
  declare module '@deepseek-ai/cordis' {
29
35
  interface Context {
@@ -32,6 +38,30 @@ declare module '@deepseek-ai/cordis' {
32
38
  }
33
39
  export declare const name = "ssh-tui-startup";
34
40
  export declare const inject: string[];
41
+ /**
42
+ * Whether another application already owns this profile's command line.
43
+ *
44
+ * `@deepseek-ai/dsh-cmdline` has **no arbitration between apps**: every app plugin is
45
+ * handed the same argv and parses it with its own grammar, and an unknown flag is
46
+ * `program.error`, which exits the process. A profile that hosts two apps therefore
47
+ * requires the second one to understand the first one's flags — and the desktop
48
+ * application passes `--no-open` (the web app's own flag) to its host, so this row's
49
+ * grammar rejected it: the host exited 1, the application reported
50
+ * `dsh desktop host exited with 1: error: unknown option '--no-open'` and died. Only
51
+ * the *startup* row did that; the TUI row never ran, which is why 0.8.1's inert-when-
52
+ * headless fix could not have caught it.
53
+ *
54
+ * The signals are two, and they overlap on purpose:
55
+ *
56
+ * - the launcher facts (`desktopLauncher`): an Electron host, `DeepSeek Harness.exe`,
57
+ * or `app.asar` in argv — true whatever the profile's row order is;
58
+ * - `webStartup`: the web app provides it as soon as it has parsed, and a `web`
59
+ * profile is not a terminal profile in any case.
60
+ *
61
+ * Stepping aside is not a degraded mode: the TUI row `inject`s `sshTuiStartup`, so
62
+ * nothing of this plugin activates and the host keeps its own grammar and its own app.
63
+ */
64
+ export declare function anotherAppOwnsCommandLine(ctx: Context): boolean;
35
65
  /**
36
66
  * Build the TUI command grammar and provide the session identity for the
37
67
  * agent-loop row plus the {@link SshTuiStartup} service. On `--help` or a
@@ -14,12 +14,41 @@
14
14
  * - decode rate needs a finite, non-negative output token count.
15
15
  */
16
16
  import type { TokenUsage } from '@deepseek-ai/dsh-llm';
17
+ import { type ThroughputView } from './throughput.js';
17
18
  export interface SessionUsage {
18
19
  inputTokens: number;
19
20
  outputTokens: number;
21
+ /** Sum of the full-call totals the harness itself reported, when it reported one. */
22
+ reportedTokens: number;
23
+ /** Steps whose usage carried a harness total. */
24
+ reportedSteps: number;
25
+ /** Steps whose usage carried none, so the billed parts had to stand in. */
26
+ unreportedSteps: number;
20
27
  cacheReadTokens: number;
21
28
  cacheWriteTokens: number;
22
29
  }
30
+ /** The session-size number the second row shows, and how it was derived. */
31
+ export interface SessionTokenTotal {
32
+ tokens: number;
33
+ /**
34
+ * `harness` when every usage sample carried the harness's own total; `sum`
35
+ * when some step's billed parts had to be added in instead.
36
+ */
37
+ basis: 'harness' | 'sum';
38
+ }
39
+ /**
40
+ * How many tokens this session has moved, on the accounting the harness
41
+ * published.
42
+ *
43
+ * The harness is the authority: a `totalTokens` it reports is a full-call total
44
+ * whose parts are defined provider by provider — pi-ai's own adapters build it
45
+ * as prompt + output + cache read + cache write — and re-deriving one from the
46
+ * three prompt counters plus output is exactly the arithmetic that
47
+ * double-counts on a provider that folds cache into its prompt count. So
48
+ * reported totals are summed, and only a step that reported none contributes
49
+ * its billed parts. The basis says which happened, and `/status` prints it.
50
+ */
51
+ export declare function sessionTokenTotal(usage: SessionUsage): SessionTokenTotal | undefined;
23
52
  export interface SessionStatsSnapshot {
24
53
  turns: number;
25
54
  steps: number;
@@ -29,6 +58,12 @@ export interface SessionStatsSnapshot {
29
58
  ttftSteps: number;
30
59
  decodeMs: number;
31
60
  decodeTokens: number;
61
+ /** Tokens and milliseconds of the most recent settled step alone. */
62
+ lastDecodeMs: number;
63
+ lastDecodeTokens: number;
64
+ /** Decode totals excluding the step that is currently open. */
65
+ settledDecodeMs: number;
66
+ settledDecodeTokens: number;
32
67
  usage: SessionUsage;
33
68
  }
34
69
  /** Flat view of the same numbers, for the footer's stats line. */
@@ -45,6 +80,7 @@ export interface SessionStatsRow {
45
80
  outputTokens: number;
46
81
  cacheReadTokens: number;
47
82
  cacheWriteTokens: number;
83
+ totalTokens: number;
48
84
  }
49
85
  export interface OpenStep {
50
86
  turn: number;
@@ -52,7 +88,7 @@ export interface OpenStep {
52
88
  }
53
89
  export declare function emptySessionUsage(): SessionUsage;
54
90
  export declare function emptySessionStats(): SessionStatsSnapshot;
55
- /** Flatten a snapshot into the footer's row shape. */
91
+ /** Flatten a snapshot into the legacy stats-row shape. */
56
92
  export declare function statsRowOf(stats: SessionStatsSnapshot): SessionStatsRow;
57
93
  /**
58
94
  * Accumulates the stats the footer and `/status` show as session events arrive.
@@ -65,10 +101,35 @@ export declare class SessionStatsTracker {
65
101
  private readonly usageByStep;
66
102
  private readonly pendingToolTimes;
67
103
  private lastTurn;
104
+ /** Live decode estimate for the step currently streaming. */
105
+ private readonly live;
68
106
  /** The step a live chunk belongs to when the host frame carried none. */
69
107
  currentStep(): OpenStep | undefined;
108
+ /**
109
+ * The open step's two clocks, for the status row's elapsed suffix.
110
+ *
111
+ * Both are host wall clocks, so a caller may subtract them from `Date.now()`
112
+ * without a translation. `firstTokenAt` is absent until the step's first
113
+ * delta, which is exactly the difference between "the request is out" and
114
+ * "the model is talking".
115
+ */
116
+ stepClocks(): {
117
+ startedAt?: number;
118
+ firstTokenAt?: number;
119
+ };
70
120
  /** `step/start`: opens the clock LLM time, TTFT and decode are measured from. */
71
121
  noteStepStart(turn: number, step: number, time: number): void;
122
+ /**
123
+ * A live fragment of model output, for the running throughput estimate.
124
+ *
125
+ * The text itself travels: the estimate is tokens per character, and how many
126
+ * tokens a character is worth depends on the script the fragment is written in.
127
+ * @param text - the fragment.
128
+ * @param time - wall clock the fragment arrived at.
129
+ */
130
+ noteDelta(text: string, time?: number): void;
131
+ /** The running throughput estimate, settled value included. */
132
+ throughput(now?: number): ThroughputView;
72
133
  /** The first token delta of the open step latches its TTFT clock. */
73
134
  noteFirstToken(turn: number, step: number, time: number): void;
74
135
  /** Replace one step's usage sample so a repeated report never double counts. */
@@ -87,6 +148,8 @@ export declare class SessionStatsTracker {
87
148
  }): void;
88
149
  /** `tool/call`: start the tool clock for this call. */
89
150
  noteToolStart(callId: string, time: number): void;
151
+ /** When a still-open tool call started, for the status row's elapsed suffix. */
152
+ toolStartedAt(callId: string): number | undefined;
90
153
  /** `tool/result`: add the elapsed tool time, once. */
91
154
  noteToolEnd(callId: string, time: number): void;
92
155
  /** `step/end`: count the step, and forget the step's usage dedupe key. */
@@ -44,13 +44,14 @@ export declare function canonicalProviderId(provider: string | undefined): strin
44
44
  */
45
45
  export declare function subagentProviderDiffers(parent: string | undefined, child: string | undefined): boolean;
46
46
  /**
47
- * Identity SGR for a subagent chip title. Same provider as the parent stays
48
- * the violet used since the courtesy-name work; a different provider uses
49
- * cyan so the foreign route is visible without a second color layer.
47
+ * Which identity role a subagent chip title wears.
48
+ *
49
+ * The colours themselves live in `theme.ts`: the same provider as the parent
50
+ * gets the identity role, a different provider gets the foreign one, and the
51
+ * palette decides what those look like (under `mono` they become an attribute,
52
+ * because the distinction has to survive a terminal without colour).
50
53
  */
51
- export declare const SUBAGENT_IDENTITY_SGR = "38;5;141";
52
- export declare const SUBAGENT_FOREIGN_SGR = "38;5;80";
53
- export declare function subagentIdentitySgr(foreign: boolean): typeof SUBAGENT_IDENTITY_SGR | typeof SUBAGENT_FOREIGN_SGR;
54
+ export declare function subagentIdentityRole(foreign: boolean): 'subagent-self' | 'subagent-foreign';
54
55
  /**
55
56
  * True when the stored subagent model still belongs to the parent provider
56
57
  * family. An explicit leftover DeepSeek flash id after switching to xAI is
@@ -40,6 +40,16 @@ export declare function waitCardCopy(input: {
40
40
  export declare function wrapWaitDetails(detail: string, width: number, maxLines?: number): string[];
41
41
  /** Forget the cached decision, so the next read re-consults the environment. */
42
42
  export declare function resetAsciiChrome(): void;
43
+ /**
44
+ * Whether chrome is being drawn in ASCII, for renderers that pick the glyph run
45
+ * themselves.
46
+ *
47
+ * `pinEmojiCells` translates them anyway; asking is for the case where the
48
+ * *shape* differs rather than just the character — a metre whose filled cell is
49
+ * `#` reads as `##..`, not as `#..#`, so the renderer wants to know before it
50
+ * counts and pads.
51
+ */
52
+ export declare function asciiChromeEnabled(): boolean;
43
53
  /**
44
54
  * Rewrite one string's chrome into ASCII when the terminal cannot decode UTF-8.
45
55
  *
@@ -53,25 +63,87 @@ export declare function resetAsciiChrome(): void;
53
63
  */
54
64
  export declare function mapAsciiChrome(text: string): string;
55
65
  /**
56
- * Terminal cell width for one string.
66
+ * Resolve the ambiguous-glyph policy for this process.
57
67
  *
58
- * Match glibc wcwidth / typical UTF-8 SSH terminals: CJK ideographs and
59
- * fullwidth forms occupy two cells; East-Asian Ambiguous box-drawing and
60
- * ornaments (`─`, `●`, `·`, `▸`, `❯`, Braille spinners) occupy one. Counting
61
- * those ambiguous glyphs as two made `repeatToWidth('─', cols)` paint a
62
- * half-width rule and parked the input cursor half a cell past the text.
68
+ * Cheap enough to call once per line (it compares one string) and far too
69
+ * expensive to call per character, which is the distinction that matters.
70
+ * @param env - the environment to read.
71
+ * @param onTerminal - whether the output is a terminal at all.
72
+ * @returns whether the glyphs are two cells wide, and whether the second cell is
73
+ * ours to reserve.
74
+ */
75
+ export declare function ambiguousPolicy(env?: NodeJS.ProcessEnv, onTerminal?: boolean): {
76
+ wide: boolean;
77
+ reserve: boolean;
78
+ };
79
+ /**
80
+ * Record what the terminal's own answer said about these glyphs.
63
81
  *
64
- * Emoji-bearing symbols are the exception: a terminal with an emoji font draws
65
- * a symbol the monospace font lacks with a colour glyph that is wider than the
66
- * cell it advances, so they are budgeted two cells. {@link pinEmojiCells} makes
67
- * the terminal actually spend both: VS15 asks for the narrow text form, and a
68
- * reserving space clears the second cell when the run does not already end in
69
- * one. Budgeting two cells without clearing the second is what left `✖`
70
- * overlapping the `|` beside it while every `▶` row came up a cell short.
82
+ * Called by the launcher after measuring; also the escape hatch for a caller
83
+ * that knows better than the locale (a relay that has already measured for its
84
+ * own accounting, or a test).
85
+ * @param wide - true when the terminal advances two cells, false for one, or
86
+ * undefined to fall back to the locale.
87
+ */
88
+ export declare function setAmbiguousWidthMeasured(wide: boolean | undefined): void;
89
+ /**
90
+ * Force the reserve behaviour, for a caller that measured the same terminal for
91
+ * its own accounting (a relay) or a test.
92
+ * @param reserve - whether to spend a space after each ambiguous glyph.
93
+ */
94
+ export declare function setAmbiguousWidthReserve(reserve: boolean): void;
95
+ /** Whether the second cell is currently reserved with a space. */
96
+ export declare function ambiguousWidthReserved(): boolean;
97
+ /**
98
+ * Cells one character costs in this TUI's layout.
71
99
  *
72
- * Overflow into the input box is handled by clipping/padding painted rows to
73
- * the measured column count, not by inflating glyph width.
100
+ * The reserve case still costs two: the glyph advances one cell and the reserving
101
+ * space takes the next, so the layout must budget both or every row holding one
102
+ * comes up short. Non-ambiguous characters are unchanged.
103
+ * @param cp - the code point.
104
+ * @returns 0, 1 or 2 cells.
74
105
  */
106
+ export declare function ambiguousCellCost(cp: number): number;
107
+ /** What the last measurement decided, for diagnostics and tests. */
108
+ export declare function ambiguousWidthMeasured(): boolean | undefined;
109
+ /**
110
+ * Whether ambiguous glyphs in {@link AMBIGUOUS_WIDE_RANGES} are drawn two cells
111
+ * wide here.
112
+ *
113
+ * `DSH_TUI_AMBIGUOUS_WIDTH=1|2` answers outright. Otherwise the locale decides,
114
+ * and only when there really is a terminal: a zh/ja/ko locale means the terminal
115
+ * is very likely using a CJK font, where these glyphs are full width. The UI
116
+ * language is deliberately *not* consulted — a Chinese reader on a Western
117
+ * terminal has narrow glyphs, and typing in Chinese does not change the font
118
+ * metrics.
119
+ * @param env - the environment to read (tests pass their own).
120
+ * @param onTerminal - whether the output is a terminal at all; a pipe or a test
121
+ * harness has no font metrics, so there the narrow default applies.
122
+ * @returns true when those glyphs should be budgeted two cells.
123
+ */
124
+ export declare function ambiguousWidthIsTwo(env?: NodeJS.ProcessEnv, onTerminal?: boolean): boolean;
125
+ /**
126
+ * Cells one *code unit* costs at its position, given a resolved policy.
127
+ *
128
+ * The per-character core of {@link displayWidth}, split out for the
129
+ * callers that walk a string themselves (clipping, truncation, wrapping): they
130
+ * resolve {@link ambiguousPolicy} once per string and then ask this per
131
+ * character, instead of paying `displayWidth`'s policy lookup and ASCII mapping
132
+ * for every character of every row. A profile of a window drag put 56% of the
133
+ * samples in that lookup, because the clipping loops are O(n²) in a way nothing
134
+ * about their shape advertises: `clipAnsiToWidth` calls this for each character,
135
+ * and `truncateToWidth` re-measures the whole remainder inside its loop.
136
+ *
137
+ * ASCII is answered from its code point before any table lookup: it is the
138
+ * overwhelming majority of every row, and it can never be ambiguous or wide.
139
+ * @param cp - the code point.
140
+ * @param policy - the resolved policy, as returned by {@link ambiguousPolicy}.
141
+ * @returns 0, 1 or 2 cells.
142
+ */
143
+ export declare function charCellWidth(cp: number, policy: {
144
+ wide: boolean;
145
+ reserve: boolean;
146
+ }): number;
75
147
  export declare function displayWidth(text: string): number;
76
148
  /**
77
149
  * Make a painted row spend the two cells {@link displayWidth} budgets for every
@@ -176,7 +248,19 @@ export declare function hrefAtColumn(hits: readonly PaintedLinkHit[], col: numbe
176
248
  * headings, quotes, lists, rules, links and inline spans keep their own ANSI.
177
249
  */
178
250
  export declare function renderMarkdownLines(text: string, width: number, color: boolean, hyperlinks?: boolean): string[];
179
- /** Cut one line to fit a width, appending an ellipsis when truncated. */
251
+ /**
252
+ * Cut **one line** to fit a width, appending an ellipsis when truncated.
253
+ *
254
+ * A line break is not a character in a row: it moves the terminal to the next row,
255
+ * and a frame that addresses its rows absolutely (`ESC[row;1H`) then writes every
256
+ * following row one line off — content lands on the composer, on the footer, and on
257
+ * top of whatever was already there. `sanitizeTerminalText` keeps LF/CR on purpose
258
+ * (it also sanitizes multi-line bodies), so the collapse happens here, where the
259
+ * contract is "one row": each break becomes a single space, which keeps the string
260
+ * the same *length* — callers that pass this result into `paintSegmentedLine` rely
261
+ * on character offsets into the text they built, and a collapsing replacement would
262
+ * silently move every colour segment.
263
+ */
180
264
  export declare function truncateToWidth(text: string, width: number): string;
181
265
  /**
182
266
  * Clip an already-styled ANSI line to `width` terminal cells without dropping
@@ -201,14 +285,6 @@ export interface InputView {
201
285
  cursorOffset: number;
202
286
  folded: boolean;
203
287
  }
204
- /**
205
- * Fold a long input into one terminal row around the cursor.
206
- *
207
- * Newlines from a paste are display-only: they do not occupy cells, so a
208
- * naive `displayWidth(input)` under-counts a multi-line paste and parks the
209
- * caret in the middle of later text. Fold the *current line* (between the
210
- * surrounding newlines) and keep `\n` out of the visible slice.
211
- */
212
288
  export declare function foldInputView(input: string, cursor: number, maxWidth: number): InputView;
213
289
  /**
214
290
  * Map a character index in the input text to its visual (row, col) after the
@@ -28,6 +28,7 @@ export declare const RTT_OUTLIER_RATIO = 0.25;
28
28
  export declare function stripCursorReplies(text: string): {
29
29
  text: string;
30
30
  replies: number;
31
+ last?: string;
31
32
  };
32
33
  /**
33
34
  * Drop cursor replies from a byte stream that is otherwise user input.
@@ -38,6 +39,8 @@ export declare function stripCursorReplies(text: string): {
38
39
  */
39
40
  export declare class TerminalInputFilter {
40
41
  private held;
42
+ /** The most recent cursor reply this filter swallowed, if any. */
43
+ lastReply: string | undefined;
41
44
  push(text: string): {
42
45
  forward: string;
43
46
  replies: number;
@@ -103,10 +106,26 @@ export declare class TerminalInputPump {
103
106
  private slowestSampleMs;
104
107
  private holdTimer;
105
108
  private listening;
109
+ /** Set by {@link quiet}: the held tail is dropped instead of forwarded. */
110
+ private dropping;
106
111
  readonly ssh: boolean;
107
112
  constructor(options: TerminalInputPumpOptions);
108
113
  start(): void;
109
114
  stop(): void;
115
+ /**
116
+ * Wait one more round trip before letting go of the terminal.
117
+ *
118
+ * A cursor reply can still be on the wire when a relay stops. The terminal
119
+ * answers a Device Status Report after the round trip, so on a slow SSH link
120
+ * that is hundreds of milliseconds after the request; restoring cooked mode
121
+ * first lets the tty echo the answer as literal `^[[25;1R` text at the user's
122
+ * prompt, and leaves it in the tty queue for the shell to read as typing.
123
+ * Staying in raw mode for one round trip swallows it instead. Typing that
124
+ * lands in this window is forwarded as usual — the window exists to drop
125
+ * answers, not keystrokes.
126
+ * @param graceMs - how long the terminal may still owe us a reply.
127
+ */
128
+ handBack(graceMs?: number): Promise<void>;
110
129
  /**
111
130
  * Ask the terminal for its cursor a few times and report the round-trip that
112
131
  * best describes the link. `undefined` means the terminal never answered in
@@ -170,5 +189,23 @@ export declare class TerminalInputPump {
170
189
  * from being attributed to the next request.
171
190
  */
172
191
  private answer;
192
+ /**
193
+ * Ask the terminal for its cursor after printing `probe`, and report where it
194
+ * says the cursor ended up.
195
+ *
196
+ * This is the measurement half of the pump: `measure()` answers "how long did
197
+ * the round trip take", this answers "how far did the cursor move". Replies are
198
+ * swallowed by the same filter that swallows them for the timing probe — which
199
+ * matters, because a reply nobody consumes is echoed on screen as literal
200
+ * `^[[1;5R` text, and that is exactly what a hand-rolled version of this did.
201
+ * @param probe - the text to print before asking (cleared afterwards).
202
+ * @param timeoutMs - how long to wait for the answer.
203
+ * @returns the coordinates the terminal reported, or undefined when it stayed
204
+ * silent.
205
+ */
206
+ askPosition(probe: string, timeoutMs?: number): Promise<{
207
+ row: number;
208
+ column: number;
209
+ } | undefined>;
173
210
  private scheduleHold;
174
211
  }