detached f8c309e

feat(auth,tui): dark login pages + /logout picker

Two unrelated UX improvements bundled:

1. Login pages (all of them) now use the TUI-matching dark style.
   Swapped the shared monoStyle from white/black to:
     - background #0a0a0a
     - white body text
     - Geist Mono via Google Fonts @import
     - accent #7ed3fc on every occurrence of the word "zot"
   Applies to: /apikey index, /apikey form, api-key success, oauth
   success, oauth error. The three pages that were still white
   (index, form, error) now match the TUI's dark look end-to-end.
   Input focus ring and button hover flipped to white-on-dark.

2. /logout without an argument opens a picker.
   New logout_dialog.go modelled on the existing small-list dialogs
   (model picker shape, session picker size). Lists only the
   providers the user is actually logged into, each with an
   (apikey) or (oauth) tag. When both are logged in, an extra
   "all" row is appended. When nothing is stored, /logout reports
   "no credentials stored; already logged out" and doesn't open
   an empty dialog.

   /logout anthropic, /logout openai, /logout all still work
   exactly as before (direct, no dialog).

Also includes the user's earlier edit to defaultIdentity:
"operating inside zot, a coding agent harness" rewording.
commit
f8c309e3ca6187e5b0d4729556ee8fd533a8957d
author
patriceckhart · · 2026-04-19 18:14 UTC
parent
e1c1e0e
signature
unsigned
refs
tag: v0.0.49
5 files · +219 -32
M internal/agent/modes/interactive.go +81 -4
@@ -176,6 +176,7 @@ type Interactive struct {
176176 skillsDialog *skillsDialog
177177 changelogDialog *changelogDialog
178178 confirmDialog *confirmDialog
179+ logoutDialog *logoutDialog
179180 suggest *slashSuggester
180181 spin *spinner
181182
@@ -227,6 +228,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
227228 skillsDialog: newSkillsDialog(),
228229 changelogDialog: newChangelogDialog(),
229230 confirmDialog: newConfirmDialog(),
231+ logoutDialog: newLogoutDialog(),
230232 suggest: newSlashSuggester(),
231233 spin: newSpinner(),
232234 }
@@ -416,7 +418,7 @@ func (i *Interactive) Run(ctx context.Context) error {
416418 // and the AfterFunc-driven invalidate got dropped on a
417419 // full channel.
418420 drainPending()
419- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() {
421+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() {
420422 requestRedraw() // keep the spinner / dialog animation moving
421423 }
422424 }
@@ -576,6 +578,8 @@ func (i *Interactive) redraw() {
576578 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
577579 case i.confirmDialog.Active():
578580 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
581+ case i.logoutDialog.Active():
582+ dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
579583 }
580584
581585 // Slash-command autocomplete: popup above the status line, only
@@ -822,6 +826,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
822826 }
823827 return false
824828 }
829+ if i.logoutDialog.Active() {
830+ if k.Kind == tui.KeyCtrlC {
831+ i.logoutDialog.Close()
832+ i.invalidate()
833+ return false
834+ }
835+ act := i.logoutDialog.HandleKey(k)
836+ if act.Select {
837+ i.doLogout(act.Target)
838+ }
839+ i.invalidate()
840+ return false
841+ }
825842 if i.jumpDialog.Active() {
826843 if k.Kind == tui.KeyCtrlC {
827844 i.jumpDialog.Close()
@@ -1192,11 +1209,15 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
11921209 case "/login":
11931210 i.dialog.Open()
11941211 case "/logout":
1195- target := "all"
11961212 if len(parts) >= 2 {
1197- target = parts[1]
1213+ // Explicit target: /logout anthropic | openai | all
1214+ i.doLogout(parts[1])
1215+ break
11981216 }
1199- i.doLogout(target)
1217+ // No arg: open the picker over whichever providers are
1218+ // currently logged in. If nothing's logged in, bail with a
1219+ // status line.
1220+ i.openLogoutDialog()
12001221 case "/model":
12011222 if len(parts) >= 2 {
12021223 i.applyModelSelection("", parts[1])
@@ -1263,6 +1284,62 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
12631284 return false
12641285 }
12651286
1287+// openLogoutDialog shows the provider picker for `/logout` with no
1288+// argument. Only providers the user is currently logged into are
1289+// listed, plus an "all" entry when more than one is present. If
1290+// nothing's logged in, writes a status line instead of opening an
1291+// empty dialog.
1292+func (i *Interactive) openLogoutDialog() {
1293+ if i.cfg.AuthManager == nil {
1294+ i.mu.Lock()
1295+ i.statusErr = "no auth manager configured"
1296+ i.mu.Unlock()
1297+ i.invalidate()
1298+ return
1299+ }
1300+ store := i.cfg.AuthManager.Store()
1301+ if store == nil {
1302+ i.mu.Lock()
1303+ i.statusErr = "auth store is not available"
1304+ i.mu.Unlock()
1305+ i.invalidate()
1306+ return
1307+ }
1308+ creds, err := store.Load()
1309+ if err != nil {
1310+ i.mu.Lock()
1311+ i.statusErr = "read auth store: " + err.Error()
1312+ i.mu.Unlock()
1313+ i.invalidate()
1314+ return
1315+ }
1316+
1317+ var items []logoutItem
1318+ for _, p := range []string{"anthropic", "openai"} {
1319+ if creds.Has(p) {
1320+ items = append(items, logoutItem{
1321+ label: p,
1322+ target: p,
1323+ method: creds.Method(p),
1324+ })
1325+ }
1326+ }
1327+ if len(items) == 0 {
1328+ i.mu.Lock()
1329+ i.statusOK = "no credentials stored; already logged out"
1330+ i.statusErr = ""
1331+ i.mu.Unlock()
1332+ i.invalidate()
1333+ return
1334+ }
1335+ if len(items) > 1 {
1336+ items = append(items, logoutItem{label: "all", target: "all"})
1337+ }
1338+
1339+ i.logoutDialog.Open(items)
1340+ i.invalidate()
1341+}
1342+
12661343 // doLogout clears credentials for the given provider (or all providers)
12671344 // from auth.json. If the active agent was using those credentials, it
12681345 // is torn down so the user is forced through /login before their next
@@ -176,6 +176,7 @@ type Interactive struct {
176 skillsDialog *skillsDialog176 skillsDialog *skillsDialog
177 changelogDialog *changelogDialog177 changelogDialog *changelogDialog
178 confirmDialog *confirmDialog178 confirmDialog *confirmDialog
179+ logoutDialog *logoutDialog
179 suggest *slashSuggester180 suggest *slashSuggester
180 spin *spinner181 spin *spinner
181 182
@@ -227,6 +228,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
227 skillsDialog: newSkillsDialog(),228 skillsDialog: newSkillsDialog(),
228 changelogDialog: newChangelogDialog(),229 changelogDialog: newChangelogDialog(),
229 confirmDialog: newConfirmDialog(),230 confirmDialog: newConfirmDialog(),
231+ logoutDialog: newLogoutDialog(),
230 suggest: newSlashSuggester(),232 suggest: newSlashSuggester(),
231 spin: newSpinner(),233 spin: newSpinner(),
232 }234 }
@@ -416,7 +418,7 @@ func (i *Interactive) Run(ctx context.Context) error {
416 // and the AfterFunc-driven invalidate got dropped on a418 // and the AfterFunc-driven invalidate got dropped on a
417 // full channel.419 // full channel.
418 drainPending()420 drainPending()
419- if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() {421+ if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() {
420 requestRedraw() // keep the spinner / dialog animation moving422 requestRedraw() // keep the spinner / dialog animation moving
421 }423 }
422 }424 }
@@ -576,6 +578,8 @@ func (i *Interactive) redraw() {
576 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)578 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
577 case i.confirmDialog.Active():579 case i.confirmDialog.Active():
578 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)580 dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
581+ case i.logoutDialog.Active():
582+ dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
579 }583 }
580 584
581 // Slash-command autocomplete: popup above the status line, only585 // Slash-command autocomplete: popup above the status line, only
@@ -822,6 +826,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
822 }826 }
823 return false827 return false
824 }828 }
829+ if i.logoutDialog.Active() {
830+ if k.Kind == tui.KeyCtrlC {
831+ i.logoutDialog.Close()
832+ i.invalidate()
833+ return false
834+ }
835+ act := i.logoutDialog.HandleKey(k)
836+ if act.Select {
837+ i.doLogout(act.Target)
838+ }
839+ i.invalidate()
840+ return false
841+ }
825 if i.jumpDialog.Active() {842 if i.jumpDialog.Active() {
826 if k.Kind == tui.KeyCtrlC {843 if k.Kind == tui.KeyCtrlC {
827 i.jumpDialog.Close()844 i.jumpDialog.Close()
@@ -1192,11 +1209,15 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1192 case "/login":1209 case "/login":
1193 i.dialog.Open()1210 i.dialog.Open()
1194 case "/logout":1211 case "/logout":
1195- target := "all"
1196 if len(parts) >= 2 {1212 if len(parts) >= 2 {
1197- target = parts[1]1213+ // Explicit target: /logout anthropic | openai | all
1214+ i.doLogout(parts[1])
1215+ break
1198 }1216 }
1199- i.doLogout(target)1217+ // No arg: open the picker over whichever providers are
1218+ // currently logged in. If nothing's logged in, bail with a
1219+ // status line.
1220+ i.openLogoutDialog()
1200 case "/model":1221 case "/model":
1201 if len(parts) >= 2 {1222 if len(parts) >= 2 {
1202 i.applyModelSelection("", parts[1])1223 i.applyModelSelection("", parts[1])
@@ -1263,6 +1284,62 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
1263 return false1284 return false
1264 }1285 }
1265 1286
1287+// openLogoutDialog shows the provider picker for `/logout` with no
1288+// argument. Only providers the user is currently logged into are
1289+// listed, plus an "all" entry when more than one is present. If
1290+// nothing's logged in, writes a status line instead of opening an
1291+// empty dialog.
1292+func (i *Interactive) openLogoutDialog() {
1293+ if i.cfg.AuthManager == nil {
1294+ i.mu.Lock()
1295+ i.statusErr = "no auth manager configured"
1296+ i.mu.Unlock()
1297+ i.invalidate()
1298+ return
1299+ }
1300+ store := i.cfg.AuthManager.Store()
1301+ if store == nil {
1302+ i.mu.Lock()
1303+ i.statusErr = "auth store is not available"
1304+ i.mu.Unlock()
1305+ i.invalidate()
1306+ return
1307+ }
1308+ creds, err := store.Load()
1309+ if err != nil {
1310+ i.mu.Lock()
1311+ i.statusErr = "read auth store: " + err.Error()
1312+ i.mu.Unlock()
1313+ i.invalidate()
1314+ return
1315+ }
1316+
1317+ var items []logoutItem
1318+ for _, p := range []string{"anthropic", "openai"} {
1319+ if creds.Has(p) {
1320+ items = append(items, logoutItem{
1321+ label: p,
1322+ target: p,
1323+ method: creds.Method(p),
1324+ })
1325+ }
1326+ }
1327+ if len(items) == 0 {
1328+ i.mu.Lock()
1329+ i.statusOK = "no credentials stored; already logged out"
1330+ i.statusErr = ""
1331+ i.mu.Unlock()
1332+ i.invalidate()
1333+ return
1334+ }
1335+ if len(items) > 1 {
1336+ items = append(items, logoutItem{label: "all", target: "all"})
1337+ }
1338+
1339+ i.logoutDialog.Open(items)
1340+ i.invalidate()
1341+}
1342+
1266 // doLogout clears credentials for the given provider (or all providers)1343 // doLogout clears credentials for the given provider (or all providers)
1267 // from auth.json. If the active agent was using those credentials, it1344 // from auth.json. If the active agent was using those credentials, it
1268 // is torn down so the user is forced through /login before their next1345 // is torn down so the user is forced through /login before their next
A internal/agent/modes/logout_dialog.go +103 -0
@@ -0,0 +1,103 @@
1+package modes
2+
3+import (
4+ "github.com/patriceckhart/zot/internal/tui"
5+)
6+
7+// logoutDialog is the picker shown when the user runs `/logout`
8+// without an argument. Lists each provider the user is currently
9+// logged into (apikey or oauth), plus an "all" entry and a cancel.
10+// Up/down to move, enter to pick, esc to cancel.
11+type logoutDialog struct {
12+ active bool
13+ items []logoutItem
14+ cursor int
15+}
16+
17+// logoutItem is one row in the picker. target is what gets passed
18+// to doLogout ("anthropic", "openai", or "all"); method is the
19+// short tag shown in muted text next to the provider ("apikey" or
20+// "oauth"), empty for the "all" row.
21+type logoutItem struct {
22+ label string
23+ target string
24+ method string
25+}
26+
27+// logoutAction is the outcome of a key press.
28+type logoutAction struct {
29+ Select bool
30+ Target string // one of: "anthropic", "openai", "all"
31+ Close bool
32+}
33+
34+func newLogoutDialog() *logoutDialog { return &logoutDialog{} }
35+
36+// Open populates the picker with whichever providers are currently
37+// logged in. Returns false if nothing to log out of; the caller
38+// should fall back to showing a status message instead of opening
39+// an empty dialog.
40+func (d *logoutDialog) Open(items []logoutItem) bool {
41+ if len(items) == 0 {
42+ return false
43+ }
44+ d.items = items
45+ d.cursor = 0
46+ d.active = true
47+ return true
48+}
49+
50+// Close hides the dialog.
51+func (d *logoutDialog) Close() { d.active = false }
52+
53+// Active reports whether the dialog is consuming input.
54+func (d *logoutDialog) Active() bool { return d != nil && d.active }
55+
56+// HandleKey advances the selection or resolves the dialog.
57+func (d *logoutDialog) HandleKey(k tui.Key) logoutAction {
58+ switch k.Kind {
59+ case tui.KeyUp:
60+ if d.cursor > 0 {
61+ d.cursor--
62+ }
63+ case tui.KeyDown:
64+ if d.cursor < len(d.items)-1 {
65+ d.cursor++
66+ }
67+ case tui.KeyEsc:
68+ d.Close()
69+ return logoutAction{Close: true}
70+ case tui.KeyEnter:
71+ if len(d.items) == 0 {
72+ d.Close()
73+ return logoutAction{Close: true}
74+ }
75+ it := d.items[d.cursor]
76+ d.Close()
77+ return logoutAction{Select: true, Target: it.target}
78+ }
79+ return logoutAction{}
80+}
81+
82+// Render returns the dialog lines.
83+func (d *logoutDialog) Render(th tui.Theme, width int) []string {
84+ if !d.Active() {
85+ return nil
86+ }
87+ var lines []string
88+ lines = append(lines, frameHeader(th, "logout", width))
89+ lines = append(lines, th.FG256(th.Muted, "choose what to log out of (\u2191/\u2193, enter, esc to cancel):"))
90+ for i, it := range d.items {
91+ plain := " " + it.label
92+ if it.method != "" {
93+ plain += " " + th.FG256(th.Muted, "("+it.method+")")
94+ }
95+ if i == d.cursor {
96+ lines = append(lines, th.PadHighlight(plain, width))
97+ } else {
98+ lines = append(lines, th.FG256(th.Muted, plain))
99+ }
100+ }
101+ lines = append(lines, frameRule(th, width))
102+ return lines
103+}
@@ -0,0 +1,103 @@
1+package modes
2+
3+import (
4+ "github.com/patriceckhart/zot/internal/tui"
5+)
6+
7+// logoutDialog is the picker shown when the user runs `/logout`
8+// without an argument. Lists each provider the user is currently
9+// logged into (apikey or oauth), plus an "all" entry and a cancel.
10+// Up/down to move, enter to pick, esc to cancel.
11+type logoutDialog struct {
12+ active bool
13+ items []logoutItem
14+ cursor int
15+}
16+
17+// logoutItem is one row in the picker. target is what gets passed
18+// to doLogout ("anthropic", "openai", or "all"); method is the
19+// short tag shown in muted text next to the provider ("apikey" or
20+// "oauth"), empty for the "all" row.
21+type logoutItem struct {
22+ label string
23+ target string
24+ method string
25+}
26+
27+// logoutAction is the outcome of a key press.
28+type logoutAction struct {
29+ Select bool
30+ Target string // one of: "anthropic", "openai", "all"
31+ Close bool
32+}
33+
34+func newLogoutDialog() *logoutDialog { return &logoutDialog{} }
35+
36+// Open populates the picker with whichever providers are currently
37+// logged in. Returns false if nothing to log out of; the caller
38+// should fall back to showing a status message instead of opening
39+// an empty dialog.
40+func (d *logoutDialog) Open(items []logoutItem) bool {
41+ if len(items) == 0 {
42+ return false
43+ }
44+ d.items = items
45+ d.cursor = 0
46+ d.active = true
47+ return true
48+}
49+
50+// Close hides the dialog.
51+func (d *logoutDialog) Close() { d.active = false }
52+
53+// Active reports whether the dialog is consuming input.
54+func (d *logoutDialog) Active() bool { return d != nil && d.active }
55+
56+// HandleKey advances the selection or resolves the dialog.
57+func (d *logoutDialog) HandleKey(k tui.Key) logoutAction {
58+ switch k.Kind {
59+ case tui.KeyUp:
60+ if d.cursor > 0 {
61+ d.cursor--
62+ }
63+ case tui.KeyDown:
64+ if d.cursor < len(d.items)-1 {
65+ d.cursor++
66+ }
67+ case tui.KeyEsc:
68+ d.Close()
69+ return logoutAction{Close: true}
70+ case tui.KeyEnter:
71+ if len(d.items) == 0 {
72+ d.Close()
73+ return logoutAction{Close: true}
74+ }
75+ it := d.items[d.cursor]
76+ d.Close()
77+ return logoutAction{Select: true, Target: it.target}
78+ }
79+ return logoutAction{}
80+}
81+
82+// Render returns the dialog lines.
83+func (d *logoutDialog) Render(th tui.Theme, width int) []string {
84+ if !d.Active() {
85+ return nil
86+ }
87+ var lines []string
88+ lines = append(lines, frameHeader(th, "logout", width))
89+ lines = append(lines, th.FG256(th.Muted, "choose what to log out of (\u2191/\u2193, enter, esc to cancel):"))
90+ for i, it := range d.items {
91+ plain := " " + it.label
92+ if it.method != "" {
93+ plain += " " + th.FG256(th.Muted, "("+it.method+")")
94+ }
95+ if i == d.cursor {
96+ lines = append(lines, th.PadHighlight(plain, width))
97+ } else {
98+ lines = append(lines, th.FG256(th.Muted, plain))
99+ }
100+ }
101+ lines = append(lines, frameRule(th, width))
102+ return lines
103+}
M internal/agent/systemprompt.go +1 -1
@@ -75,6 +75,6 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
7575 return sb.String()
7676 }
7777
78-const defaultIdentity = `You are zot, a lightweight terminal coding agent. The name stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.
78+const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.
7979
8080 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.`
@@ -75,6 +75,6 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
75 return sb.String()75 return sb.String()
76 }76 }
77 77
78-const defaultIdentity = `You are zot, a lightweight terminal coding agent. The name stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.78+const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.
79 79
80 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.`80 Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.`
M internal/auth/callback.go +25 -18
@@ -122,16 +122,18 @@ func (cs *CallbackServer) deliver(r CallbackResult) {
122122
123123 // ---- static HTML for the callback tab ----
124124
125-// All zot-served pages share a single style: white background, black
126-// text, the cyan pixel-art `z` logo at the top, thin black rules,
127-// monospace type.
125+// All zot-served pages share a single dark style matching the TUI:
126+// near-black background (#0a0a0a), white text, Geist Mono type, cyan
127+// accent on the word "zot". Serves every step of both the api-key
128+// flow and the oauth callback flow.
128129 const monoStyle = `<style>
129- :root { color-scheme: light; }
130+ @import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;600&display=swap');
131+ :root { color-scheme: dark; }
130132 * { box-sizing: border-box; }
131133 body {
132- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
133- background: #ffffff;
134- color: #000000;
134+ font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
135+ background: #0a0a0a;
136+ color: #ffffff;
135137 max-width: 44rem;
136138 margin: 0 auto;
137139 padding: 3rem 1.5rem;
@@ -152,23 +154,28 @@ const monoStyle = `<style>
152154 letter-spacing: 0.01em;
153155 }
154156 h1 .mark { display: inline-block; width: 1.25rem; }
155- .rule { border: 0; border-top: 1px solid #000000; margin: 1.5rem 0; }
156- .muted { color: #595959; }
157- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; }
157+ .zot { color: #7ed3fc; }
158+ .rule { border: 0; border-top: 1px solid #ffffff; margin: 1.5rem 0; }
159+ .muted { color: #9ca3af; }
160+ .mono { font-family: inherit; word-break: break-all; }
158161 .msg { padding: 0.75rem 0; }
159- a { color: inherit; }
162+ a { color: #7ed3fc; }
160163 input[type=password], input[type=text] {
161164 width: 100%; padding: 0.5rem 0.6rem;
162- border: 1px solid #000000; background: #ffffff; color: #000000;
165+ background: #0a0a0a; color: #ffffff;
166+ border: 1px solid #ffffff;
163167 font-family: inherit; font-size: 0.95rem;
164168 }
169+ input[type=password]:focus, input[type=text]:focus {
170+ outline: none; border-color: #7ed3fc;
171+ }
165172 button {
166173 padding: 0.5rem 1.25rem;
167- background: #000000; color: #ffffff;
168- border: 1px solid #000000; font-family: inherit; font-size: 0.95rem;
174+ background: #ffffff; color: #0a0a0a;
175+ border: 1px solid #ffffff; font-family: inherit; font-size: 0.95rem;
169176 cursor: pointer;
170177 }
171- button:hover { background: #ffffff; color: #000000; }
178+ button:hover { background: #0a0a0a; color: #ffffff; }
172179 </style>`
173180
174181 // logoTag is the <img> element used at the top of every zot-served
@@ -189,17 +196,17 @@ func oauthSuccessHTML(provider string) string {
189196 ` + logoTag + `
190197 <h1><span class="mark">✓</span> logged in to ` + p + `</h1>
191198 <hr class="rule">
192-<p class="msg">zot received the callback. you can close this tab.</p>
199+<p class="msg"><span class="zot">zot</span> received the callback. you can close this tab.</p>
193200 </body></html>`
194201 }
195202
196203 func oauthErrorHTML(msg string) string {
197- return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · error</title>` + monoStyle + `</head><body>
204+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - error</title>` + monoStyle + `</head><body>
198205 ` + logoTag + `
199206 <h1><span class="mark">✗</span> login failed</h1>
200207 <hr class="rule">
201208 <p class="msg mono">` + htmlEscape(msg) + `</p>
202-<p class="muted">go back to zot and try again.</p>
209+<p class="muted">go back to <span class="zot">zot</span> and try again.</p>
203210 </body></html>`
204211 }
205212
@@ -122,16 +122,18 @@ func (cs *CallbackServer) deliver(r CallbackResult) {
122 122
123 // ---- static HTML for the callback tab ----123 // ---- static HTML for the callback tab ----
124 124
125-// All zot-served pages share a single style: white background, black125+// All zot-served pages share a single dark style matching the TUI:
126-// text, the cyan pixel-art `z` logo at the top, thin black rules,126+// near-black background (#0a0a0a), white text, Geist Mono type, cyan
127-// monospace type.127+// accent on the word "zot". Serves every step of both the api-key
128+// flow and the oauth callback flow.
128 const monoStyle = `<style>129 const monoStyle = `<style>
129- :root { color-scheme: light; }130+ @import url('https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;600&display=swap');
131+ :root { color-scheme: dark; }
130 * { box-sizing: border-box; }132 * { box-sizing: border-box; }
131 body {133 body {
132- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;134+ font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
133- background: #ffffff;135+ background: #0a0a0a;
134- color: #000000;136+ color: #ffffff;
135 max-width: 44rem;137 max-width: 44rem;
136 margin: 0 auto;138 margin: 0 auto;
137 padding: 3rem 1.5rem;139 padding: 3rem 1.5rem;
@@ -152,23 +154,28 @@ const monoStyle = `<style>
152 letter-spacing: 0.01em;154 letter-spacing: 0.01em;
153 }155 }
154 h1 .mark { display: inline-block; width: 1.25rem; }156 h1 .mark { display: inline-block; width: 1.25rem; }
155- .rule { border: 0; border-top: 1px solid #000000; margin: 1.5rem 0; }157+ .zot { color: #7ed3fc; }
156- .muted { color: #595959; }158+ .rule { border: 0; border-top: 1px solid #ffffff; margin: 1.5rem 0; }
157- .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; word-break: break-all; }159+ .muted { color: #9ca3af; }
160+ .mono { font-family: inherit; word-break: break-all; }
158 .msg { padding: 0.75rem 0; }161 .msg { padding: 0.75rem 0; }
159- a { color: inherit; }162+ a { color: #7ed3fc; }
160 input[type=password], input[type=text] {163 input[type=password], input[type=text] {
161 width: 100%; padding: 0.5rem 0.6rem;164 width: 100%; padding: 0.5rem 0.6rem;
162- border: 1px solid #000000; background: #ffffff; color: #000000;165+ background: #0a0a0a; color: #ffffff;
166+ border: 1px solid #ffffff;
163 font-family: inherit; font-size: 0.95rem;167 font-family: inherit; font-size: 0.95rem;
164 }168 }
169+ input[type=password]:focus, input[type=text]:focus {
170+ outline: none; border-color: #7ed3fc;
171+ }
165 button {172 button {
166 padding: 0.5rem 1.25rem;173 padding: 0.5rem 1.25rem;
167- background: #000000; color: #ffffff;174+ background: #ffffff; color: #0a0a0a;
168- border: 1px solid #000000; font-family: inherit; font-size: 0.95rem;175+ border: 1px solid #ffffff; font-family: inherit; font-size: 0.95rem;
169 cursor: pointer;176 cursor: pointer;
170 }177 }
171- button:hover { background: #ffffff; color: #000000; }178+ button:hover { background: #0a0a0a; color: #ffffff; }
172 </style>`179 </style>`
173 180
174 // logoTag is the <img> element used at the top of every zot-served181 // logoTag is the <img> element used at the top of every zot-served
@@ -189,17 +196,17 @@ func oauthSuccessHTML(provider string) string {
189 ` + logoTag + `196 ` + logoTag + `
190 <h1><span class="mark">✓</span> logged in to ` + p + `</h1>197 <h1><span class="mark">✓</span> logged in to ` + p + `</h1>
191 <hr class="rule">198 <hr class="rule">
192-<p class="msg">zot received the callback. you can close this tab.</p>199+<p class="msg"><span class="zot">zot</span> received the callback. you can close this tab.</p>
193 </body></html>`200 </body></html>`
194 }201 }
195 202
196 func oauthErrorHTML(msg string) string {203 func oauthErrorHTML(msg string) string {
197- return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · error</title>` + monoStyle + `</head><body>204+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - error</title>` + monoStyle + `</head><body>
198 ` + logoTag + `205 ` + logoTag + `
199 <h1><span class="mark">✗</span> login failed</h1>206 <h1><span class="mark">✗</span> login failed</h1>
200 <hr class="rule">207 <hr class="rule">
201 <p class="msg mono">` + htmlEscape(msg) + `</p>208 <p class="msg mono">` + htmlEscape(msg) + `</p>
202-<p class="muted">go back to zot and try again.</p>209+<p class="muted">go back to <span class="zot">zot</span> and try again.</p>
203 </body></html>`210 </body></html>`
204 }211 }
205 212
M internal/auth/server.go +9 -9
@@ -221,15 +221,15 @@ func Redirect(w http.ResponseWriter, r *http.Request, u *url.URL) {
221221
222222 var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot login</title>` + monoStyle + `</head><body>
223223 ` + logoTag + `
224-<h1>zot login</h1>
224+<h1><span class="zot">zot</span> login</h1>
225225 <hr class="rule">
226-<p>paste an api key for anthropic or openai. zot probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
226+<p>paste an api key for anthropic or openai. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
227227 <p>
228228 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
229229 <a href="/apikey?provider=openai">openai api key →</a>
230230 </p>
231231 <hr class="rule">
232-<p class="muted">for a subscription login (claude pro/max · chatgpt plus/pro), close this tab and run /login inside zot.</p>
232+<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro), close this tab and run /login inside <span class="zot">zot</span>.</p>
233233 </body></html>`))
234234
235235 func init() {
@@ -238,9 +238,9 @@ func init() {
238238 label { font-size: 0.875rem; }
239239 </style></head><body>
240240 ` + logoTag + `
241-<h1>zot login · {{.Provider}} api key</h1>
241+<h1><span class="zot">zot</span> login - {{.Provider}} api key</h1>
242242 <hr class="rule">
243-<p>paste your {{.Provider}} api key. zot will probe the provider with it once, then save it if the key is accepted.</p>
243+<p>paste your {{.Provider}} api key. <span class="zot">zot</span> will probe the provider with it once, then save it if the key is accepted.</p>
244244 <form method="POST" action="/apikey">
245245 <input type="hidden" name="provider" value="{{.Provider}}" />
246246 <label for="api_key">api key</label>
@@ -249,19 +249,19 @@ func init() {
249249 </form>
250250 </body></html>`))
251251
252- template.Must(tpl.New("success").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · logged in</title>` + monoStyle + `</head><body>
252+ template.Must(tpl.New("success").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
253253 ` + logoTag + `
254254 <h1><span class="mark">✓</span> logged in to {{.Provider}}</h1>
255255 <hr class="rule">
256256 <p class="msg">method: {{.Method}}</p>
257-<p class="muted">zot received the callback. you can close this tab and return to the terminal.</p>
257+<p class="muted"><span class="zot">zot</span> received the callback. you can close this tab and return to the terminal.</p>
258258 </body></html>`))
259259
260- template.Must(tpl.New("error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · error</title>` + monoStyle + `</head><body>
260+ template.Must(tpl.New("error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - error</title>` + monoStyle + `</head><body>
261261 ` + logoTag + `
262262 <h1><span class="mark">✗</span> login failed</h1>
263263 <hr class="rule">
264264 <p class="msg mono">{{.Message}}</p>
265-<p class="muted">go back to zot and try again.</p>
265+<p class="muted">go back to <span class="zot">zot</span> and try again.</p>
266266 </body></html>`))
267267 }
@@ -221,15 +221,15 @@ func Redirect(w http.ResponseWriter, r *http.Request, u *url.URL) {
221 221
222 var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot login</title>` + monoStyle + `</head><body>222 var tpl = template.Must(template.New("index").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot login</title>` + monoStyle + `</head><body>
223 ` + logoTag + `223 ` + logoTag + `
224-<h1>zot login</h1>224+<h1><span class="zot">zot</span> login</h1>
225 <hr class="rule">225 <hr class="rule">
226-<p>paste an api key for anthropic or openai. zot probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>226+<p>paste an api key for anthropic or openai. <span class="zot">zot</span> probes the provider once, then saves the key to <span class="mono">~/Library/Application Support/zot/auth.json</span>.</p>
227 <p>227 <p>
228 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>228 <a href="/apikey?provider=anthropic">anthropic api key →</a><br>
229 <a href="/apikey?provider=openai">openai api key →</a>229 <a href="/apikey?provider=openai">openai api key →</a>
230 </p>230 </p>
231 <hr class="rule">231 <hr class="rule">
232-<p class="muted">for a subscription login (claude pro/max · chatgpt plus/pro), close this tab and run /login inside zot.</p>232+<p class="muted">for a subscription login (claude pro/max - chatgpt plus/pro), close this tab and run /login inside <span class="zot">zot</span>.</p>
233 </body></html>`))233 </body></html>`))
234 234
235 func init() {235 func init() {
@@ -238,9 +238,9 @@ func init() {
238 label { font-size: 0.875rem; }238 label { font-size: 0.875rem; }
239 </style></head><body>239 </style></head><body>
240 ` + logoTag + `240 ` + logoTag + `
241-<h1>zot login · {{.Provider}} api key</h1>241+<h1><span class="zot">zot</span> login - {{.Provider}} api key</h1>
242 <hr class="rule">242 <hr class="rule">
243-<p>paste your {{.Provider}} api key. zot will probe the provider with it once, then save it if the key is accepted.</p>243+<p>paste your {{.Provider}} api key. <span class="zot">zot</span> will probe the provider with it once, then save it if the key is accepted.</p>
244 <form method="POST" action="/apikey">244 <form method="POST" action="/apikey">
245 <input type="hidden" name="provider" value="{{.Provider}}" />245 <input type="hidden" name="provider" value="{{.Provider}}" />
246 <label for="api_key">api key</label>246 <label for="api_key">api key</label>
@@ -249,19 +249,19 @@ func init() {
249 </form>249 </form>
250 </body></html>`))250 </body></html>`))
251 251
252- template.Must(tpl.New("success").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · logged in</title>` + monoStyle + `</head><body>252+ template.Must(tpl.New("success").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - logged in</title>` + monoStyle + `</head><body>
253 ` + logoTag + `253 ` + logoTag + `
254 <h1><span class="mark">✓</span> logged in to {{.Provider}}</h1>254 <h1><span class="mark">✓</span> logged in to {{.Provider}}</h1>
255 <hr class="rule">255 <hr class="rule">
256 <p class="msg">method: {{.Method}}</p>256 <p class="msg">method: {{.Method}}</p>
257-<p class="muted">zot received the callback. you can close this tab and return to the terminal.</p>257+<p class="muted"><span class="zot">zot</span> received the callback. you can close this tab and return to the terminal.</p>
258 </body></html>`))258 </body></html>`))
259 259
260- template.Must(tpl.New("error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot · error</title>` + monoStyle + `</head><body>260+ template.Must(tpl.New("error").Parse(`<!doctype html><html lang="en"><head><meta charset="utf-8"/><title>zot - error</title>` + monoStyle + `</head><body>
261 ` + logoTag + `261 ` + logoTag + `
262 <h1><span class="mark">✗</span> login failed</h1>262 <h1><span class="mark">✗</span> login failed</h1>
263 <hr class="rule">263 <hr class="rule">
264 <p class="msg mono">{{.Message}}</p>264 <p class="msg mono">{{.Message}}</p>
265-<p class="muted">go back to zot and try again.</p>265+<p class="muted">go back to <span class="zot">zot</span> and try again.</p>
266 </body></html>`))266 </body></html>`))
267 }267 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close